@leo000001/opencode-quota-sidebar 1.0.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/CHANGELOG.md +70 -0
- package/CONTRIBUTING.md +102 -0
- package/LICENSE +21 -0
- package/README.md +216 -0
- package/SECURITY.md +26 -0
- package/dist/cache.d.ts +6 -0
- package/dist/cache.js +22 -0
- package/dist/cost.d.ts +13 -0
- package/dist/cost.js +76 -0
- package/dist/format.d.ts +21 -0
- package/dist/format.js +426 -0
- package/dist/helpers.d.ts +14 -0
- package/dist/helpers.js +50 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +699 -0
- package/dist/period.d.ts +1 -0
- package/dist/period.js +14 -0
- package/dist/providers/common.d.ts +24 -0
- package/dist/providers/common.js +114 -0
- package/dist/providers/core/anthropic.d.ts +2 -0
- package/dist/providers/core/anthropic.js +46 -0
- package/dist/providers/core/copilot.d.ts +2 -0
- package/dist/providers/core/copilot.js +117 -0
- package/dist/providers/core/openai.d.ts +2 -0
- package/dist/providers/core/openai.js +159 -0
- package/dist/providers/index.d.ts +8 -0
- package/dist/providers/index.js +14 -0
- package/dist/providers/registry.d.ts +9 -0
- package/dist/providers/registry.js +38 -0
- package/dist/providers/third_party/rightcode.d.ts +2 -0
- package/dist/providers/third_party/rightcode.js +230 -0
- package/dist/providers/types.d.ts +58 -0
- package/dist/providers/types.js +1 -0
- package/dist/quota.d.ts +49 -0
- package/dist/quota.js +116 -0
- package/dist/quota_render.d.ts +5 -0
- package/dist/quota_render.js +85 -0
- package/dist/storage.d.ts +32 -0
- package/dist/storage.js +328 -0
- package/dist/storage_chunks.d.ts +9 -0
- package/dist/storage_chunks.js +147 -0
- package/dist/storage_dates.d.ts +9 -0
- package/dist/storage_dates.js +88 -0
- package/dist/storage_parse.d.ts +4 -0
- package/dist/storage_parse.js +149 -0
- package/dist/storage_paths.d.ts +14 -0
- package/dist/storage_paths.js +31 -0
- package/dist/title.d.ts +8 -0
- package/dist/title.js +38 -0
- package/dist/types.d.ts +116 -0
- package/dist/types.js +1 -0
- package/dist/usage.d.ts +51 -0
- package/dist/usage.js +243 -0
- package/package.json +68 -0
- package/quota-sidebar.config.example.json +25 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { tool } from '@opencode-ai/plugin/tool';
|
|
3
|
+
import { renderMarkdownReport, renderSidebarTitle, renderToastMessage, } from './format.js';
|
|
4
|
+
import { createQuotaRuntime, listDefaultQuotaProviderIDs, loadAuthMap, quotaSort, } from './quota.js';
|
|
5
|
+
import { authFilePath, dateKeyFromTimestamp, evictOldSessions, loadConfig, loadState, normalizeTimestampMs, resolveOpencodeDataDir, saveState, scanSessionsByCreatedRange, stateFilePath, } from './storage.js';
|
|
6
|
+
import { debug, isRecord, mapConcurrent, swallow } from './helpers.js';
|
|
7
|
+
import { calcEquivalentApiCostForMessage, canonicalApiCostProviderID, modelCostKey, parseModelCostRates, SUBSCRIPTION_API_COST_PROVIDERS, } from './cost.js';
|
|
8
|
+
import { canonicalizeTitle, looksDecorated, normalizeBaseTitle, } from './title.js';
|
|
9
|
+
import { periodStart } from './period.js';
|
|
10
|
+
import { emptyUsageSummary, fromCachedSessionUsage, mergeUsage, summarizeMessagesIncremental, toCachedSessionUsage, } from './usage.js';
|
|
11
|
+
import { TtlValueCache } from './cache.js';
|
|
12
|
+
const z = tool.schema;
|
|
13
|
+
function isAssistantMessage(message) {
|
|
14
|
+
return message.role === 'assistant';
|
|
15
|
+
}
|
|
16
|
+
export async function QuotaSidebarPlugin(input) {
|
|
17
|
+
const quotaRuntime = createQuotaRuntime();
|
|
18
|
+
const config = await loadConfig([
|
|
19
|
+
path.join(input.directory, 'quota-sidebar.config.json'),
|
|
20
|
+
path.join(input.worktree, 'quota-sidebar.config.json'),
|
|
21
|
+
]);
|
|
22
|
+
const dataDir = resolveOpencodeDataDir();
|
|
23
|
+
const statePath = stateFilePath(dataDir);
|
|
24
|
+
const authPath = authFilePath(dataDir);
|
|
25
|
+
const state = await loadState(statePath);
|
|
26
|
+
// M2: evict old sessions on startup
|
|
27
|
+
evictOldSessions(state, config.retentionDays);
|
|
28
|
+
const refreshTimer = new Map();
|
|
29
|
+
const pendingAppliedTitle = new Map();
|
|
30
|
+
const dirtyDateKeys = new Set();
|
|
31
|
+
// Per-session queue for applyTitle
|
|
32
|
+
const applyTitleLocks = new Map();
|
|
33
|
+
// M1: track sessions that have been cleaned up from refreshTimer
|
|
34
|
+
// (we clean up on each scheduleTitleRefresh call)
|
|
35
|
+
// P1: track sessions needing full rescan (after message.removed)
|
|
36
|
+
const forceRescanSessions = new Set();
|
|
37
|
+
const authCache = new TtlValueCache();
|
|
38
|
+
const getAuthMap = async () => {
|
|
39
|
+
const cached = authCache.get();
|
|
40
|
+
if (cached)
|
|
41
|
+
return cached;
|
|
42
|
+
const value = await loadAuthMap(authPath);
|
|
43
|
+
return authCache.set(value, 30_000);
|
|
44
|
+
};
|
|
45
|
+
const providerOptionsCache = new TtlValueCache();
|
|
46
|
+
const getProviderOptionsMap = async () => {
|
|
47
|
+
const cached = providerOptionsCache.get();
|
|
48
|
+
if (cached)
|
|
49
|
+
return cached;
|
|
50
|
+
const configClient = input.client;
|
|
51
|
+
if (!configClient.config?.providers) {
|
|
52
|
+
return providerOptionsCache.set({}, 30_000);
|
|
53
|
+
}
|
|
54
|
+
const response = await configClient.config
|
|
55
|
+
.providers({
|
|
56
|
+
query: { directory: input.directory },
|
|
57
|
+
throwOnError: true,
|
|
58
|
+
})
|
|
59
|
+
.catch(swallow('getProviderOptionsMap'));
|
|
60
|
+
const data = response &&
|
|
61
|
+
typeof response === 'object' &&
|
|
62
|
+
'data' in response &&
|
|
63
|
+
response.data &&
|
|
64
|
+
typeof response.data === 'object' &&
|
|
65
|
+
'providers' in response.data
|
|
66
|
+
? response.data.providers
|
|
67
|
+
: undefined;
|
|
68
|
+
const map = Array.isArray(data)
|
|
69
|
+
? data.reduce((acc, item) => {
|
|
70
|
+
if (!item || typeof item !== 'object')
|
|
71
|
+
return acc;
|
|
72
|
+
const record = item;
|
|
73
|
+
const id = record.id;
|
|
74
|
+
const options = record.options;
|
|
75
|
+
if (typeof id !== 'string')
|
|
76
|
+
return acc;
|
|
77
|
+
if (!options ||
|
|
78
|
+
typeof options !== 'object' ||
|
|
79
|
+
Array.isArray(options)) {
|
|
80
|
+
acc[id] = {};
|
|
81
|
+
return acc;
|
|
82
|
+
}
|
|
83
|
+
acc[id] = options;
|
|
84
|
+
return acc;
|
|
85
|
+
}, {})
|
|
86
|
+
: {};
|
|
87
|
+
return providerOptionsCache.set(map, 30_000);
|
|
88
|
+
};
|
|
89
|
+
const modelCostCache = new TtlValueCache();
|
|
90
|
+
const missingApiCostRateKeys = new Set();
|
|
91
|
+
const getModelCostMap = async () => {
|
|
92
|
+
const cached = modelCostCache.get();
|
|
93
|
+
if (cached)
|
|
94
|
+
return cached;
|
|
95
|
+
const providerClient = input.client;
|
|
96
|
+
if (!providerClient.provider?.list) {
|
|
97
|
+
return modelCostCache.set({}, 30_000);
|
|
98
|
+
}
|
|
99
|
+
const response = await providerClient.provider
|
|
100
|
+
.list({
|
|
101
|
+
query: { directory: input.directory },
|
|
102
|
+
throwOnError: true,
|
|
103
|
+
})
|
|
104
|
+
.catch(swallow('getModelCostMap'));
|
|
105
|
+
const all = response &&
|
|
106
|
+
typeof response === 'object' &&
|
|
107
|
+
'data' in response &&
|
|
108
|
+
isRecord(response.data) &&
|
|
109
|
+
Array.isArray(response.data.all)
|
|
110
|
+
? response.data.all
|
|
111
|
+
: [];
|
|
112
|
+
const map = all.reduce((acc, provider) => {
|
|
113
|
+
if (!isRecord(provider))
|
|
114
|
+
return acc;
|
|
115
|
+
const providerID = typeof provider.id === 'string'
|
|
116
|
+
? canonicalApiCostProviderID(provider.id)
|
|
117
|
+
: undefined;
|
|
118
|
+
if (!providerID)
|
|
119
|
+
return acc;
|
|
120
|
+
if (!SUBSCRIPTION_API_COST_PROVIDERS.has(providerID))
|
|
121
|
+
return acc;
|
|
122
|
+
const models = isRecord(provider.models) ? provider.models : undefined;
|
|
123
|
+
if (!models)
|
|
124
|
+
return acc;
|
|
125
|
+
for (const [modelKey, modelValue] of Object.entries(models)) {
|
|
126
|
+
if (!isRecord(modelValue))
|
|
127
|
+
continue;
|
|
128
|
+
const rates = parseModelCostRates(modelValue.cost);
|
|
129
|
+
if (!rates)
|
|
130
|
+
continue;
|
|
131
|
+
const modelID = typeof modelValue.id === 'string' ? modelValue.id : modelKey;
|
|
132
|
+
acc[modelCostKey(providerID, modelID)] = rates;
|
|
133
|
+
if (modelKey !== modelID) {
|
|
134
|
+
acc[modelCostKey(providerID, modelKey)] = rates;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return acc;
|
|
138
|
+
}, {});
|
|
139
|
+
return modelCostCache.set(map, Math.max(30_000, config.quota.refreshMs));
|
|
140
|
+
};
|
|
141
|
+
const calcEquivalentApiCost = (message, modelCostMap) => {
|
|
142
|
+
const providerID = canonicalApiCostProviderID(message.providerID);
|
|
143
|
+
if (!SUBSCRIPTION_API_COST_PROVIDERS.has(providerID))
|
|
144
|
+
return 0;
|
|
145
|
+
const rates = modelCostMap[modelCostKey(providerID, message.modelID)];
|
|
146
|
+
if (!rates) {
|
|
147
|
+
const key = modelCostKey(providerID, message.modelID);
|
|
148
|
+
if (!missingApiCostRateKeys.has(key)) {
|
|
149
|
+
missingApiCostRateKeys.add(key);
|
|
150
|
+
debug(`apiCost skipped: no model price for ${key}`);
|
|
151
|
+
}
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
return calcEquivalentApiCostForMessage(message, rates);
|
|
155
|
+
};
|
|
156
|
+
let saveTimer;
|
|
157
|
+
let saveInFlight = Promise.resolve();
|
|
158
|
+
/**
|
|
159
|
+
* H2 fix: capture and delete specific dirty keys instead of clearing the whole set.
|
|
160
|
+
* Keys added between capture and write completion are preserved.
|
|
161
|
+
*/
|
|
162
|
+
const persistState = () => {
|
|
163
|
+
const dirty = Array.from(dirtyDateKeys);
|
|
164
|
+
if (dirty.length === 0)
|
|
165
|
+
return saveInFlight;
|
|
166
|
+
// H2: delete only the captured keys, not clear()
|
|
167
|
+
for (const key of dirty) {
|
|
168
|
+
dirtyDateKeys.delete(key);
|
|
169
|
+
}
|
|
170
|
+
const write = saveInFlight
|
|
171
|
+
.catch(swallow('persistState:wait'))
|
|
172
|
+
.then(() => saveState(statePath, state, { dirtyDateKeys: dirty }))
|
|
173
|
+
.catch((error) => {
|
|
174
|
+
// Re-add captured keys so they are not lost on failed persistence.
|
|
175
|
+
for (const key of dirty) {
|
|
176
|
+
dirtyDateKeys.add(key);
|
|
177
|
+
}
|
|
178
|
+
throw error;
|
|
179
|
+
})
|
|
180
|
+
.catch(swallow('persistState:save'));
|
|
181
|
+
saveInFlight = write;
|
|
182
|
+
return write;
|
|
183
|
+
};
|
|
184
|
+
const scheduleSave = () => {
|
|
185
|
+
if (saveTimer)
|
|
186
|
+
clearTimeout(saveTimer);
|
|
187
|
+
saveTimer = setTimeout(() => {
|
|
188
|
+
saveTimer = undefined;
|
|
189
|
+
void persistState();
|
|
190
|
+
}, 200);
|
|
191
|
+
};
|
|
192
|
+
/**
|
|
193
|
+
* M5 fix: always flush current dirty keys, even when no timer is pending.
|
|
194
|
+
*/
|
|
195
|
+
const flushSave = async () => {
|
|
196
|
+
if (saveTimer) {
|
|
197
|
+
clearTimeout(saveTimer);
|
|
198
|
+
saveTimer = undefined;
|
|
199
|
+
}
|
|
200
|
+
// M5: always persist if there are dirty keys, regardless of timer state
|
|
201
|
+
if (dirtyDateKeys.size > 0) {
|
|
202
|
+
await persistState();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
await saveInFlight;
|
|
206
|
+
};
|
|
207
|
+
const ensureSessionState = (sessionID, title, createdAt = Date.now()) => {
|
|
208
|
+
const existing = state.sessions[sessionID];
|
|
209
|
+
if (existing) {
|
|
210
|
+
if (!state.sessionDateMap[sessionID]) {
|
|
211
|
+
state.sessionDateMap[sessionID] = dateKeyFromTimestamp(existing.createdAt);
|
|
212
|
+
}
|
|
213
|
+
return existing;
|
|
214
|
+
}
|
|
215
|
+
const normalizedCreatedAt = normalizeTimestampMs(createdAt);
|
|
216
|
+
const created = {
|
|
217
|
+
createdAt: normalizedCreatedAt,
|
|
218
|
+
baseTitle: normalizeBaseTitle(title),
|
|
219
|
+
lastAppliedTitle: undefined,
|
|
220
|
+
usage: undefined,
|
|
221
|
+
cursor: undefined,
|
|
222
|
+
};
|
|
223
|
+
state.sessions[sessionID] = created;
|
|
224
|
+
state.sessionDateMap[sessionID] = dateKeyFromTimestamp(normalizedCreatedAt);
|
|
225
|
+
dirtyDateKeys.add(state.sessionDateMap[sessionID]);
|
|
226
|
+
return created;
|
|
227
|
+
};
|
|
228
|
+
const loadSessionEntries = async (sessionID) => {
|
|
229
|
+
const response = await input.client.session
|
|
230
|
+
.messages({
|
|
231
|
+
path: { id: sessionID },
|
|
232
|
+
query: { directory: input.directory },
|
|
233
|
+
throwOnError: true,
|
|
234
|
+
})
|
|
235
|
+
.catch(swallow('loadSessionEntries'));
|
|
236
|
+
return response?.data ?? [];
|
|
237
|
+
};
|
|
238
|
+
/**
|
|
239
|
+
* P1: Incremental usage aggregation for current session.
|
|
240
|
+
*/
|
|
241
|
+
const summarizeSessionUsage = async (sessionID) => {
|
|
242
|
+
const entries = await loadSessionEntries(sessionID);
|
|
243
|
+
const modelCostMap = await getModelCostMap();
|
|
244
|
+
const sessionState = state.sessions[sessionID];
|
|
245
|
+
const forceRescan = forceRescanSessions.has(sessionID);
|
|
246
|
+
if (forceRescan)
|
|
247
|
+
forceRescanSessions.delete(sessionID);
|
|
248
|
+
const { usage, cursor } = summarizeMessagesIncremental(entries, sessionState?.usage, sessionState?.cursor, forceRescan, {
|
|
249
|
+
calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
|
|
250
|
+
});
|
|
251
|
+
usage.sessionCount = 1;
|
|
252
|
+
// Update cursor in state
|
|
253
|
+
if (sessionState) {
|
|
254
|
+
sessionState.cursor = cursor;
|
|
255
|
+
}
|
|
256
|
+
return usage;
|
|
257
|
+
};
|
|
258
|
+
/**
|
|
259
|
+
* M10 fix: parallelize API calls for range usage with concurrency limit.
|
|
260
|
+
*/
|
|
261
|
+
const summarizeRangeUsage = async (period) => {
|
|
262
|
+
const startAt = periodStart(period);
|
|
263
|
+
await flushSave();
|
|
264
|
+
// M9: pass memoryState so we prefer in-memory data
|
|
265
|
+
const sessions = await scanSessionsByCreatedRange(statePath, startAt, Date.now(), state);
|
|
266
|
+
const usage = emptyUsageSummary();
|
|
267
|
+
usage.sessionCount = sessions.length;
|
|
268
|
+
const modelCostMap = await getModelCostMap();
|
|
269
|
+
const shouldRecomputeApiCost = (cached) => {
|
|
270
|
+
if (cached.assistantMessages <= 0)
|
|
271
|
+
return false;
|
|
272
|
+
if (cached.apiCost > 0)
|
|
273
|
+
return false;
|
|
274
|
+
if (cached.total <= 0)
|
|
275
|
+
return false;
|
|
276
|
+
return true;
|
|
277
|
+
};
|
|
278
|
+
// Separate sessions with cached usage from those needing API calls
|
|
279
|
+
const needsFetch = [];
|
|
280
|
+
for (const session of sessions) {
|
|
281
|
+
if (session.state.usage) {
|
|
282
|
+
if (shouldRecomputeApiCost(session.state.usage)) {
|
|
283
|
+
needsFetch.push(session);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
mergeUsage(usage, fromCachedSessionUsage(session.state.usage, 0));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
needsFetch.push(session);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// M10: fetch in parallel with concurrency limit
|
|
294
|
+
if (needsFetch.length > 0) {
|
|
295
|
+
const fetched = await mapConcurrent(needsFetch, 5, async (session) => {
|
|
296
|
+
const entries = await loadSessionEntries(session.sessionID);
|
|
297
|
+
const { usage: computed } = summarizeMessagesIncremental(entries, undefined, undefined, true, {
|
|
298
|
+
calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
|
|
299
|
+
});
|
|
300
|
+
return { sessionID: session.sessionID, computed };
|
|
301
|
+
});
|
|
302
|
+
let dirty = false;
|
|
303
|
+
for (const { sessionID, computed } of fetched) {
|
|
304
|
+
// Range stats already know the session count (sessions.length).
|
|
305
|
+
// Do not double-count sessionCount when merging per-session summaries.
|
|
306
|
+
mergeUsage(usage, { ...computed, sessionCount: 0 });
|
|
307
|
+
const memoryState = state.sessions[sessionID];
|
|
308
|
+
if (memoryState) {
|
|
309
|
+
memoryState.usage = toCachedSessionUsage(computed);
|
|
310
|
+
const dateKey = state.sessionDateMap[sessionID] ||
|
|
311
|
+
dateKeyFromTimestamp(memoryState.createdAt);
|
|
312
|
+
state.sessionDateMap[sessionID] = dateKey;
|
|
313
|
+
dirtyDateKeys.add(dateKey);
|
|
314
|
+
dirty = true;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (dirty)
|
|
318
|
+
scheduleSave();
|
|
319
|
+
}
|
|
320
|
+
return usage;
|
|
321
|
+
};
|
|
322
|
+
const getQuotaSnapshots = async (providerIDs, options) => {
|
|
323
|
+
const isValidQuotaCache = (snapshot) => {
|
|
324
|
+
// Guard against stale RightCode cache entries from pre-daily format.
|
|
325
|
+
if (snapshot.adapterID !== 'rightcode' || snapshot.status !== 'ok') {
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
if (!snapshot.windows || snapshot.windows.length === 0)
|
|
329
|
+
return true;
|
|
330
|
+
const primary = snapshot.windows[0];
|
|
331
|
+
if (!primary.label.startsWith('Daily $'))
|
|
332
|
+
return false;
|
|
333
|
+
if (primary.showPercent !== false)
|
|
334
|
+
return false;
|
|
335
|
+
return true;
|
|
336
|
+
};
|
|
337
|
+
const [authMap, providerOptionsMap] = await Promise.all([
|
|
338
|
+
getAuthMap(),
|
|
339
|
+
getProviderOptionsMap(),
|
|
340
|
+
]);
|
|
341
|
+
const optionsForProvider = (providerID) => {
|
|
342
|
+
return (providerOptionsMap[providerID] ||
|
|
343
|
+
providerOptionsMap[quotaRuntime.normalizeProviderID(providerID)]);
|
|
344
|
+
};
|
|
345
|
+
const directCandidates = providerIDs.map((providerID) => ({
|
|
346
|
+
providerID,
|
|
347
|
+
providerOptions: optionsForProvider(providerID),
|
|
348
|
+
}));
|
|
349
|
+
const defaultCandidates = options?.allowDefault
|
|
350
|
+
? [
|
|
351
|
+
...Object.keys(providerOptionsMap).map((providerID) => ({
|
|
352
|
+
providerID,
|
|
353
|
+
providerOptions: providerOptionsMap[providerID],
|
|
354
|
+
})),
|
|
355
|
+
...listDefaultQuotaProviderIDs().map((providerID) => ({
|
|
356
|
+
providerID,
|
|
357
|
+
providerOptions: optionsForProvider(providerID),
|
|
358
|
+
})),
|
|
359
|
+
]
|
|
360
|
+
: [];
|
|
361
|
+
const rawCandidates = directCandidates.length
|
|
362
|
+
? directCandidates
|
|
363
|
+
: defaultCandidates;
|
|
364
|
+
const matchedCandidates = rawCandidates.filter((candidate) => Boolean(quotaRuntime.resolveQuotaAdapter(candidate.providerID, candidate.providerOptions)));
|
|
365
|
+
const dedupedCandidates = Array.from(matchedCandidates
|
|
366
|
+
.reduce((acc, candidate) => {
|
|
367
|
+
const key = quotaRuntime.quotaCacheKey(candidate.providerID, candidate.providerOptions);
|
|
368
|
+
if (!acc.has(key))
|
|
369
|
+
acc.set(key, candidate);
|
|
370
|
+
return acc;
|
|
371
|
+
}, new Map())
|
|
372
|
+
.values());
|
|
373
|
+
const fetched = await Promise.all(dedupedCandidates.map(async ({ providerID, providerOptions }) => {
|
|
374
|
+
const cacheKey = quotaRuntime.quotaCacheKey(providerID, providerOptions);
|
|
375
|
+
const cached = state.quotaCache[cacheKey];
|
|
376
|
+
if (cached && Date.now() - cached.checkedAt <= config.quota.refreshMs) {
|
|
377
|
+
if (isValidQuotaCache(cached)) {
|
|
378
|
+
return cached;
|
|
379
|
+
}
|
|
380
|
+
delete state.quotaCache[cacheKey];
|
|
381
|
+
}
|
|
382
|
+
const latest = await quotaRuntime.fetchQuotaSnapshot(providerID, authMap, config, async (id, auth) => {
|
|
383
|
+
await input.client.auth
|
|
384
|
+
.set({
|
|
385
|
+
path: { id },
|
|
386
|
+
query: { directory: input.directory },
|
|
387
|
+
body: {
|
|
388
|
+
type: auth.type,
|
|
389
|
+
access: auth.access,
|
|
390
|
+
refresh: auth.refresh,
|
|
391
|
+
expires: auth.expires,
|
|
392
|
+
enterpriseUrl: auth.enterpriseUrl,
|
|
393
|
+
},
|
|
394
|
+
throwOnError: true,
|
|
395
|
+
})
|
|
396
|
+
.catch(swallow('getQuotaSnapshots:authSet'));
|
|
397
|
+
}, providerOptions);
|
|
398
|
+
if (!latest)
|
|
399
|
+
return undefined;
|
|
400
|
+
state.quotaCache[cacheKey] = latest;
|
|
401
|
+
return latest;
|
|
402
|
+
}));
|
|
403
|
+
const snapshots = fetched.filter((value) => Boolean(value));
|
|
404
|
+
snapshots.sort(quotaSort);
|
|
405
|
+
scheduleSave();
|
|
406
|
+
return snapshots;
|
|
407
|
+
};
|
|
408
|
+
/**
|
|
409
|
+
* Per-session apply queue.
|
|
410
|
+
* New updates chain behind the previous one to preserve ordering.
|
|
411
|
+
*/
|
|
412
|
+
const applyTitle = async (sessionID) => {
|
|
413
|
+
const previous = applyTitleLocks.get(sessionID) ?? Promise.resolve();
|
|
414
|
+
const promise = previous
|
|
415
|
+
.catch(() => undefined)
|
|
416
|
+
.then(() => applyTitleInner(sessionID));
|
|
417
|
+
applyTitleLocks.set(sessionID, promise);
|
|
418
|
+
try {
|
|
419
|
+
await promise;
|
|
420
|
+
}
|
|
421
|
+
finally {
|
|
422
|
+
if (applyTitleLocks.get(sessionID) === promise) {
|
|
423
|
+
applyTitleLocks.delete(sessionID);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
const applyTitleInner = async (sessionID) => {
|
|
428
|
+
if (!config.sidebar.enabled || !state.titleEnabled)
|
|
429
|
+
return;
|
|
430
|
+
const session = await input.client.session
|
|
431
|
+
.get({
|
|
432
|
+
path: { id: sessionID },
|
|
433
|
+
query: { directory: input.directory },
|
|
434
|
+
throwOnError: true,
|
|
435
|
+
})
|
|
436
|
+
.catch(swallow('applyTitle:getSession'));
|
|
437
|
+
if (!session)
|
|
438
|
+
return;
|
|
439
|
+
const sessionState = ensureSessionState(sessionID, session.data.title, session.data.time.created);
|
|
440
|
+
// Detect whether the current title is our own decorated form.
|
|
441
|
+
const currentTitle = session.data.title;
|
|
442
|
+
if (canonicalizeTitle(currentTitle) !==
|
|
443
|
+
canonicalizeTitle(sessionState.lastAppliedTitle || '')) {
|
|
444
|
+
if (looksDecorated(currentTitle)) {
|
|
445
|
+
// Ignore decorated echoes as base-title source.
|
|
446
|
+
debug(`ignoring decorated current title for session ${sessionID}`);
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
sessionState.baseTitle = normalizeBaseTitle(currentTitle);
|
|
450
|
+
}
|
|
451
|
+
sessionState.lastAppliedTitle = undefined;
|
|
452
|
+
}
|
|
453
|
+
const usage = await summarizeSessionUsage(sessionID);
|
|
454
|
+
const quotaProviders = Array.from(new Set(Object.keys(usage.providers).map((id) => quotaRuntime.normalizeProviderID(id))));
|
|
455
|
+
const quotas = config.sidebar.showQuota && quotaProviders.length > 0
|
|
456
|
+
? await getQuotaSnapshots(quotaProviders)
|
|
457
|
+
: [];
|
|
458
|
+
const nextTitle = renderSidebarTitle(sessionState.baseTitle, usage, quotas, config);
|
|
459
|
+
sessionState.usage = toCachedSessionUsage(usage);
|
|
460
|
+
dirtyDateKeys.add(state.sessionDateMap[sessionID]);
|
|
461
|
+
if (canonicalizeTitle(nextTitle) === canonicalizeTitle(session.data.title)) {
|
|
462
|
+
scheduleSave();
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
// Mark pending title to ignore the immediate echo `session.updated` event.
|
|
466
|
+
// H3 fix: use longer TTL (15s) and add decoration detection as backup.
|
|
467
|
+
pendingAppliedTitle.set(sessionID, {
|
|
468
|
+
title: nextTitle,
|
|
469
|
+
expiresAt: Date.now() + 15_000,
|
|
470
|
+
});
|
|
471
|
+
const previousApplied = sessionState.lastAppliedTitle;
|
|
472
|
+
sessionState.lastAppliedTitle = nextTitle;
|
|
473
|
+
dirtyDateKeys.add(state.sessionDateMap[sessionID]);
|
|
474
|
+
const updated = await input.client.session
|
|
475
|
+
.update({
|
|
476
|
+
path: { id: sessionID },
|
|
477
|
+
query: { directory: input.directory },
|
|
478
|
+
body: { title: nextTitle },
|
|
479
|
+
throwOnError: true,
|
|
480
|
+
})
|
|
481
|
+
.catch(swallow('applyTitle:update'));
|
|
482
|
+
if (!updated) {
|
|
483
|
+
pendingAppliedTitle.delete(sessionID);
|
|
484
|
+
sessionState.lastAppliedTitle = previousApplied;
|
|
485
|
+
scheduleSave();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
pendingAppliedTitle.delete(sessionID);
|
|
489
|
+
scheduleSave();
|
|
490
|
+
};
|
|
491
|
+
const scheduleTitleRefresh = (sessionID, delay = 250) => {
|
|
492
|
+
// M1: clean up completed timer entry before setting new one
|
|
493
|
+
const previous = refreshTimer.get(sessionID);
|
|
494
|
+
if (previous)
|
|
495
|
+
clearTimeout(previous);
|
|
496
|
+
const timer = setTimeout(() => {
|
|
497
|
+
refreshTimer.delete(sessionID);
|
|
498
|
+
void applyTitle(sessionID).catch(swallow('scheduleTitleRefresh'));
|
|
499
|
+
}, delay);
|
|
500
|
+
refreshTimer.set(sessionID, timer);
|
|
501
|
+
};
|
|
502
|
+
const restoreSessionTitle = async (sessionID) => {
|
|
503
|
+
const session = await input.client.session
|
|
504
|
+
.get({
|
|
505
|
+
path: { id: sessionID },
|
|
506
|
+
query: { directory: input.directory },
|
|
507
|
+
throwOnError: true,
|
|
508
|
+
})
|
|
509
|
+
.catch(swallow('restoreSessionTitle:get'));
|
|
510
|
+
if (!session)
|
|
511
|
+
return;
|
|
512
|
+
const sessionState = ensureSessionState(sessionID, session.data.title, session.data.time.created);
|
|
513
|
+
const baseTitle = normalizeBaseTitle(sessionState.baseTitle);
|
|
514
|
+
if (session.data.title === baseTitle)
|
|
515
|
+
return;
|
|
516
|
+
await input.client.session
|
|
517
|
+
.update({
|
|
518
|
+
path: { id: sessionID },
|
|
519
|
+
query: { directory: input.directory },
|
|
520
|
+
body: { title: baseTitle },
|
|
521
|
+
throwOnError: true,
|
|
522
|
+
})
|
|
523
|
+
.catch(swallow('restoreSessionTitle:update'));
|
|
524
|
+
sessionState.lastAppliedTitle = undefined;
|
|
525
|
+
dirtyDateKeys.add(state.sessionDateMap[sessionID]);
|
|
526
|
+
scheduleSave();
|
|
527
|
+
};
|
|
528
|
+
/**
|
|
529
|
+
* P3 fix: concurrency-limited title restoration.
|
|
530
|
+
*/
|
|
531
|
+
const restoreAllVisibleTitles = async () => {
|
|
532
|
+
const list = await input.client.session
|
|
533
|
+
.list({
|
|
534
|
+
query: { directory: input.directory },
|
|
535
|
+
throwOnError: true,
|
|
536
|
+
})
|
|
537
|
+
.catch(swallow('restoreAllVisibleTitles:list'));
|
|
538
|
+
if (!list?.data)
|
|
539
|
+
return;
|
|
540
|
+
// Only restore sessions we've touched (have lastAppliedTitle)
|
|
541
|
+
const touched = list.data.filter((s) => state.sessions[s.id]?.lastAppliedTitle);
|
|
542
|
+
// P3: limit concurrency to 5
|
|
543
|
+
await mapConcurrent(touched, 5, async (s) => {
|
|
544
|
+
await restoreSessionTitle(s.id);
|
|
545
|
+
});
|
|
546
|
+
};
|
|
547
|
+
const summarizeForTool = async (period, sessionID) => {
|
|
548
|
+
if (period === 'session')
|
|
549
|
+
return summarizeSessionUsage(sessionID);
|
|
550
|
+
return summarizeRangeUsage(period);
|
|
551
|
+
};
|
|
552
|
+
const showToast = async (period, message) => {
|
|
553
|
+
await input.client.tui
|
|
554
|
+
.showToast({
|
|
555
|
+
query: { directory: input.directory },
|
|
556
|
+
body: {
|
|
557
|
+
title: `Quota ${period}`,
|
|
558
|
+
message,
|
|
559
|
+
variant: 'info',
|
|
560
|
+
duration: config.toast.durationMs,
|
|
561
|
+
},
|
|
562
|
+
throwOnError: true,
|
|
563
|
+
})
|
|
564
|
+
.catch(swallow('showToast'));
|
|
565
|
+
};
|
|
566
|
+
const onEvent = async (event) => {
|
|
567
|
+
if (event.type === 'session.created') {
|
|
568
|
+
ensureSessionState(event.properties.info.id, event.properties.info.title, event.properties.info.time.created);
|
|
569
|
+
scheduleSave();
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (event.type === 'session.updated') {
|
|
573
|
+
const sessionState = ensureSessionState(event.properties.info.id, event.properties.info.title, event.properties.info.time.created);
|
|
574
|
+
const pending = pendingAppliedTitle.get(event.properties.info.id);
|
|
575
|
+
if (pending) {
|
|
576
|
+
if (pending.expiresAt > Date.now()) {
|
|
577
|
+
if (canonicalizeTitle(event.properties.info.title) ===
|
|
578
|
+
canonicalizeTitle(pending.title)) {
|
|
579
|
+
pendingAppliedTitle.delete(event.properties.info.id);
|
|
580
|
+
sessionState.lastAppliedTitle = pending.title;
|
|
581
|
+
dirtyDateKeys.add(state.sessionDateMap[event.properties.info.id]);
|
|
582
|
+
scheduleSave();
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
pendingAppliedTitle.delete(event.properties.info.id);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
// H3 fix: if the incoming title looks decorated, it's likely a late echo
|
|
591
|
+
// of our own update. Extract the base title from line 1 instead of
|
|
592
|
+
// treating the whole decorated string as the new base title.
|
|
593
|
+
const incomingTitle = event.properties.info.title;
|
|
594
|
+
if (canonicalizeTitle(incomingTitle) ===
|
|
595
|
+
canonicalizeTitle(sessionState.lastAppliedTitle || '')) {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (looksDecorated(incomingTitle)) {
|
|
599
|
+
// Late echo — ignore as base-title source.
|
|
600
|
+
debug(`ignoring late decorated echo for session ${event.properties.info.id}`);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
sessionState.baseTitle = normalizeBaseTitle(incomingTitle);
|
|
605
|
+
}
|
|
606
|
+
sessionState.lastAppliedTitle = undefined;
|
|
607
|
+
dirtyDateKeys.add(state.sessionDateMap[event.properties.info.id]);
|
|
608
|
+
scheduleSave();
|
|
609
|
+
// External rename detected — re-render sidebar with new base title
|
|
610
|
+
scheduleTitleRefresh(event.properties.info.id);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (event.type === 'message.removed') {
|
|
614
|
+
// P1: mark session for full rescan since message order changed
|
|
615
|
+
forceRescanSessions.add(event.properties.sessionID);
|
|
616
|
+
// Also invalidate cached usage
|
|
617
|
+
const sessionState = state.sessions[event.properties.sessionID];
|
|
618
|
+
if (sessionState) {
|
|
619
|
+
sessionState.usage = undefined;
|
|
620
|
+
sessionState.cursor = undefined;
|
|
621
|
+
const dateKey = state.sessionDateMap[event.properties.sessionID] ||
|
|
622
|
+
dateKeyFromTimestamp(sessionState.createdAt);
|
|
623
|
+
state.sessionDateMap[event.properties.sessionID] = dateKey;
|
|
624
|
+
dirtyDateKeys.add(dateKey);
|
|
625
|
+
scheduleSave();
|
|
626
|
+
}
|
|
627
|
+
scheduleTitleRefresh(event.properties.sessionID);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (event.type !== 'message.updated')
|
|
631
|
+
return;
|
|
632
|
+
if (!isAssistantMessage(event.properties.info))
|
|
633
|
+
return;
|
|
634
|
+
if (!event.properties.info.time.completed)
|
|
635
|
+
return;
|
|
636
|
+
scheduleTitleRefresh(event.properties.info.sessionID);
|
|
637
|
+
};
|
|
638
|
+
return {
|
|
639
|
+
event: async ({ event }) => {
|
|
640
|
+
try {
|
|
641
|
+
await onEvent(event);
|
|
642
|
+
}
|
|
643
|
+
catch (error) {
|
|
644
|
+
debug(`event handler failed: ${String(error)}`);
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
tool: {
|
|
648
|
+
quota_summary: tool({
|
|
649
|
+
description: 'Show usage and quota summary for session/day/week/month.',
|
|
650
|
+
args: {
|
|
651
|
+
period: z.enum(['session', 'day', 'week', 'month']).optional(),
|
|
652
|
+
toast: z.boolean().optional(),
|
|
653
|
+
},
|
|
654
|
+
execute: async (args, context) => {
|
|
655
|
+
const period = args.period || 'session';
|
|
656
|
+
const usage = await summarizeForTool(period, context.sessionID);
|
|
657
|
+
// For quota_summary, always show all subscription quota balances,
|
|
658
|
+
// regardless of which providers were used in the session.
|
|
659
|
+
const quotas = await getQuotaSnapshots([], { allowDefault: true });
|
|
660
|
+
const markdown = renderMarkdownReport(period, usage, quotas, {
|
|
661
|
+
showCost: config.sidebar.showCost,
|
|
662
|
+
});
|
|
663
|
+
if (args.toast !== false) {
|
|
664
|
+
await showToast(period, renderToastMessage(period, usage, quotas, {
|
|
665
|
+
showCost: config.sidebar.showCost,
|
|
666
|
+
width: Math.max(44, config.sidebar.width + 18),
|
|
667
|
+
}));
|
|
668
|
+
}
|
|
669
|
+
return markdown;
|
|
670
|
+
},
|
|
671
|
+
}),
|
|
672
|
+
quota_show: tool({
|
|
673
|
+
description: 'Toggle sidebar title display mode. When on, titles show token usage and quota; when off, titles revert to original.',
|
|
674
|
+
args: {
|
|
675
|
+
enabled: z
|
|
676
|
+
.boolean()
|
|
677
|
+
.optional()
|
|
678
|
+
.describe('Explicit on/off. Omit to toggle current state.'),
|
|
679
|
+
},
|
|
680
|
+
execute: async (args, context) => {
|
|
681
|
+
const next = args.enabled !== undefined ? args.enabled : !state.titleEnabled;
|
|
682
|
+
state.titleEnabled = next;
|
|
683
|
+
scheduleSave();
|
|
684
|
+
if (next) {
|
|
685
|
+
// Turning on — re-render current session immediately
|
|
686
|
+
scheduleTitleRefresh(context.sessionID, 0);
|
|
687
|
+
await showToast('toggle', 'Sidebar usage display: ON');
|
|
688
|
+
return 'Sidebar usage display is now ON. Session titles will show token usage and quota.';
|
|
689
|
+
}
|
|
690
|
+
// Turning off — restore all touched sessions to base titles
|
|
691
|
+
await restoreAllVisibleTitles();
|
|
692
|
+
await showToast('toggle', 'Sidebar usage display: OFF');
|
|
693
|
+
return 'Sidebar usage display is now OFF. Session titles restored to original.';
|
|
694
|
+
},
|
|
695
|
+
}),
|
|
696
|
+
},
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
export default QuotaSidebarPlugin;
|