@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/README.md +20 -4
- package/dist/cost.js +5 -2
- package/dist/descendants.d.ts +22 -0
- package/dist/descendants.js +78 -0
- package/dist/events.d.ts +8 -0
- package/dist/events.js +31 -0
- package/dist/format.js +127 -23
- package/dist/index.js +190 -631
- package/dist/persistence.d.ts +13 -0
- package/dist/persistence.js +63 -0
- package/dist/providers/core/openai.js +2 -1
- package/dist/providers/third_party/rightcode.js +12 -11
- package/dist/quota.js +18 -8
- package/dist/quota_service.d.ts +23 -0
- package/dist/quota_service.js +188 -0
- package/dist/storage.d.ts +2 -0
- package/dist/storage.js +62 -24
- package/dist/storage_chunks.js +74 -1
- package/dist/storage_parse.js +8 -0
- package/dist/storage_paths.d.ts +1 -0
- package/dist/storage_paths.js +12 -4
- package/dist/title.d.ts +5 -0
- package/dist/title.js +26 -2
- package/dist/title_apply.d.ts +33 -0
- package/dist/title_apply.js +189 -0
- package/dist/title_refresh.d.ts +9 -0
- package/dist/title_refresh.js +46 -0
- package/dist/tools.d.ts +56 -0
- package/dist/tools.js +63 -0
- package/dist/types.d.ts +12 -0
- package/dist/usage.js +148 -47
- package/dist/usage_service.d.ts +31 -0
- package/dist/usage_service.js +417 -0
- package/package.json +1 -1
- package/quota-sidebar.config.example.json +5 -1
package/dist/index.js
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { tool } from '@opencode-ai/plugin/tool';
|
|
3
2
|
import { renderMarkdownReport, renderSidebarTitle, renderToastMessage, } from './format.js';
|
|
4
|
-
import { createQuotaRuntime
|
|
5
|
-
import { authFilePath, dateKeyFromTimestamp, evictOldSessions, loadConfig, loadState, normalizeTimestampMs, resolveOpencodeDataDir, saveState,
|
|
6
|
-
import { debug,
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
3
|
+
import { createQuotaRuntime } from './quota.js';
|
|
4
|
+
import { authFilePath, dateKeyFromTimestamp, deleteSessionFromDayChunk, evictOldSessions, loadConfig, loadState, normalizeTimestampMs, resolveOpencodeDataDir, saveState, stateFilePath, } from './storage.js';
|
|
5
|
+
import { debug, swallow } from './helpers.js';
|
|
6
|
+
import { normalizeBaseTitle } from './title.js';
|
|
7
|
+
import { createDescendantsResolver } from './descendants.js';
|
|
8
|
+
import { createTitleRefreshScheduler } from './title_refresh.js';
|
|
9
|
+
import { createQuotaSidebarTools } from './tools.js';
|
|
10
|
+
import { createEventDispatcher } from './events.js';
|
|
11
|
+
import { createPersistenceScheduler } from './persistence.js';
|
|
12
|
+
import { createQuotaService } from './quota_service.js';
|
|
13
|
+
import { createUsageService } from './usage_service.js';
|
|
14
|
+
import { createTitleApplicator } from './title_apply.js';
|
|
16
15
|
export async function QuotaSidebarPlugin(input) {
|
|
17
16
|
const quotaRuntime = createQuotaRuntime();
|
|
18
17
|
const config = await loadConfig([
|
|
@@ -25,190 +24,43 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
25
24
|
const state = await loadState(statePath);
|
|
26
25
|
// M2: evict old sessions on startup
|
|
27
26
|
evictOldSessions(state, config.retentionDays);
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
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()) => {
|
|
27
|
+
const persistence = createPersistenceScheduler({
|
|
28
|
+
statePath,
|
|
29
|
+
state,
|
|
30
|
+
saveState: (path, st, options) => saveState(path, st, options),
|
|
31
|
+
});
|
|
32
|
+
const markDirty = persistence.markDirty;
|
|
33
|
+
const scheduleSave = persistence.scheduleSave;
|
|
34
|
+
const flushSave = persistence.flushSave;
|
|
35
|
+
const RESTORE_TITLE_CONCURRENCY = 5;
|
|
36
|
+
const quotaService = createQuotaService({
|
|
37
|
+
quotaRuntime,
|
|
38
|
+
config,
|
|
39
|
+
state,
|
|
40
|
+
authPath,
|
|
41
|
+
client: input.client,
|
|
42
|
+
directory: input.directory,
|
|
43
|
+
scheduleSave,
|
|
44
|
+
});
|
|
45
|
+
const getQuotaSnapshots = quotaService.getQuotaSnapshots;
|
|
46
|
+
const ensureSessionState = (sessionID, title, createdAt = Date.now(), parentID) => {
|
|
208
47
|
const existing = state.sessions[sessionID];
|
|
209
48
|
if (existing) {
|
|
49
|
+
if (parentID !== undefined) {
|
|
50
|
+
const nextParentID = parentID ?? undefined;
|
|
51
|
+
if (existing.parentID !== nextParentID) {
|
|
52
|
+
existing.parentID = nextParentID;
|
|
53
|
+
const dateKey = state.sessionDateMap[sessionID] ||
|
|
54
|
+
dateKeyFromTimestamp(existing.createdAt);
|
|
55
|
+
state.sessionDateMap[sessionID] = dateKey;
|
|
56
|
+
markDirty(dateKey);
|
|
57
|
+
scheduleSave();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
210
60
|
if (!state.sessionDateMap[sessionID]) {
|
|
211
61
|
state.sessionDateMap[sessionID] = dateKeyFromTimestamp(existing.createdAt);
|
|
62
|
+
markDirty(state.sessionDateMap[sessionID]);
|
|
63
|
+
scheduleSave();
|
|
212
64
|
}
|
|
213
65
|
return existing;
|
|
214
66
|
}
|
|
@@ -217,338 +69,98 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
217
69
|
createdAt: normalizedCreatedAt,
|
|
218
70
|
baseTitle: normalizeBaseTitle(title),
|
|
219
71
|
lastAppliedTitle: undefined,
|
|
72
|
+
parentID: parentID ?? undefined,
|
|
220
73
|
usage: undefined,
|
|
221
74
|
cursor: undefined,
|
|
222
75
|
};
|
|
223
76
|
state.sessions[sessionID] = created;
|
|
224
77
|
state.sessionDateMap[sessionID] = dateKeyFromTimestamp(normalizedCreatedAt);
|
|
225
|
-
|
|
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);
|
|
78
|
+
markDirty(state.sessionDateMap[sessionID]);
|
|
405
79
|
scheduleSave();
|
|
406
|
-
return
|
|
80
|
+
return created;
|
|
407
81
|
};
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
}
|
|
82
|
+
const descendantsResolver = createDescendantsResolver({
|
|
83
|
+
listChildren: async (sessionID) => {
|
|
84
|
+
const sessionClient = input.client;
|
|
85
|
+
if (!sessionClient.session?.children)
|
|
86
|
+
return [];
|
|
87
|
+
const response = await sessionClient.session
|
|
88
|
+
.children({
|
|
89
|
+
path: { id: sessionID },
|
|
90
|
+
query: { directory: input.directory },
|
|
91
|
+
throwOnError: true,
|
|
92
|
+
})
|
|
93
|
+
.catch(swallow('listSessionChildren'));
|
|
94
|
+
return response?.data ?? [];
|
|
95
|
+
},
|
|
96
|
+
getParentID: (sessionID) => state.sessions[sessionID]?.parentID,
|
|
97
|
+
onDiscover: (session) => {
|
|
98
|
+
ensureSessionState(session.id, session.title, session.createdAt, session.parentID ?? null);
|
|
99
|
+
},
|
|
100
|
+
debug,
|
|
101
|
+
});
|
|
102
|
+
const usageService = createUsageService({
|
|
103
|
+
state,
|
|
104
|
+
config,
|
|
105
|
+
statePath,
|
|
106
|
+
client: input.client,
|
|
107
|
+
directory: input.directory,
|
|
108
|
+
persistence: {
|
|
109
|
+
markDirty,
|
|
110
|
+
scheduleSave,
|
|
111
|
+
flushSave,
|
|
112
|
+
},
|
|
113
|
+
descendantsResolver,
|
|
114
|
+
});
|
|
115
|
+
const summarizeSessionUsageForDisplay = usageService.summarizeSessionUsageForDisplay;
|
|
116
|
+
const summarizeForTool = usageService.summarizeForTool;
|
|
117
|
+
// title apply / refresh lifecycle
|
|
118
|
+
let scheduleTitleRefresh = (sessionID, delay = 250) => {
|
|
119
|
+
void sessionID;
|
|
120
|
+
void delay;
|
|
426
121
|
};
|
|
427
|
-
const
|
|
428
|
-
if (!config.sidebar.
|
|
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)
|
|
122
|
+
const scheduleParentRefreshIfSafe = (sessionID, parentID) => {
|
|
123
|
+
if (!config.sidebar.includeChildren)
|
|
438
124
|
return;
|
|
439
|
-
|
|
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();
|
|
125
|
+
if (!parentID)
|
|
463
126
|
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();
|
|
127
|
+
if (parentID === sessionID)
|
|
486
128
|
return;
|
|
129
|
+
// Guard against cycles in parent chains that would cause endless refresh.
|
|
130
|
+
const seen = new Set([sessionID]);
|
|
131
|
+
let current = parentID;
|
|
132
|
+
const maxHops = 512;
|
|
133
|
+
for (let i = 0; i < maxHops && current; i++) {
|
|
134
|
+
if (seen.has(current)) {
|
|
135
|
+
debug(`skip parent refresh due to parentID cycle: ${sessionID} -> ${parentID}`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
seen.add(current);
|
|
139
|
+
current = state.sessions[current]?.parentID;
|
|
487
140
|
}
|
|
488
|
-
|
|
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);
|
|
141
|
+
scheduleTitleRefresh(parentID, 0);
|
|
551
142
|
};
|
|
143
|
+
const titleApplicator = createTitleApplicator({
|
|
144
|
+
state,
|
|
145
|
+
config,
|
|
146
|
+
client: input.client,
|
|
147
|
+
directory: input.directory,
|
|
148
|
+
ensureSessionState,
|
|
149
|
+
markDirty,
|
|
150
|
+
scheduleSave,
|
|
151
|
+
renderSidebarTitle,
|
|
152
|
+
quotaRuntime,
|
|
153
|
+
getQuotaSnapshots,
|
|
154
|
+
summarizeSessionUsageForDisplay,
|
|
155
|
+
scheduleParentRefreshIfSafe,
|
|
156
|
+
restoreConcurrency: RESTORE_TITLE_CONCURRENCY,
|
|
157
|
+
});
|
|
158
|
+
const titleRefresh = createTitleRefreshScheduler({
|
|
159
|
+
apply: titleApplicator.applyTitle,
|
|
160
|
+
onError: swallow('titleRefresh'),
|
|
161
|
+
});
|
|
162
|
+
scheduleTitleRefresh = titleRefresh.schedule;
|
|
163
|
+
const restoreAllVisibleTitles = titleApplicator.restoreAllVisibleTitles;
|
|
552
164
|
const showToast = async (period, message) => {
|
|
553
165
|
await input.client.tui
|
|
554
166
|
.showToast({
|
|
@@ -563,137 +175,84 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
563
175
|
})
|
|
564
176
|
.catch(swallow('showToast'));
|
|
565
177
|
};
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
ensureSessionState(
|
|
178
|
+
const dispatchEvent = createEventDispatcher({
|
|
179
|
+
onSessionCreated: async (session) => {
|
|
180
|
+
ensureSessionState(session.id, session.title, session.time.created, session.parentID ?? null);
|
|
181
|
+
descendantsResolver.invalidateForAncestors(session.parentID);
|
|
569
182
|
scheduleSave();
|
|
570
|
-
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
const
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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);
|
|
183
|
+
scheduleParentRefreshIfSafe(session.id, session.parentID);
|
|
184
|
+
},
|
|
185
|
+
onSessionUpdated: async (session) => {
|
|
186
|
+
const existing = state.sessions[session.id];
|
|
187
|
+
const oldParentID = existing?.parentID;
|
|
188
|
+
const sessionState = ensureSessionState(session.id, session.title, session.time.created, session.parentID ?? null);
|
|
189
|
+
const newParentID = sessionState.parentID;
|
|
190
|
+
const parentMoved = config.sidebar.includeChildren && oldParentID !== newParentID;
|
|
191
|
+
descendantsResolver.invalidateForAncestors(oldParentID);
|
|
192
|
+
descendantsResolver.invalidateForAncestors(newParentID);
|
|
193
|
+
// If this session moved between parents, refresh both sides even if we
|
|
194
|
+
// later return early due to title echo/decorated-title handling.
|
|
195
|
+
if (parentMoved) {
|
|
196
|
+
scheduleParentRefreshIfSafe(session.id, oldParentID);
|
|
197
|
+
scheduleParentRefreshIfSafe(session.id, newParentID);
|
|
605
198
|
}
|
|
606
|
-
|
|
607
|
-
|
|
199
|
+
await titleApplicator.handleSessionUpdatedTitle({
|
|
200
|
+
sessionID: session.id,
|
|
201
|
+
incomingTitle: session.title,
|
|
202
|
+
sessionState,
|
|
203
|
+
scheduleRefresh: titleRefresh.schedule,
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
onSessionDeleted: async (session) => {
|
|
207
|
+
await flushSave().catch(swallow('onSessionDeleted:flushSave'));
|
|
208
|
+
descendantsResolver.invalidateForAncestors(session.parentID);
|
|
209
|
+
descendantsResolver.invalidateForAncestors(session.id);
|
|
210
|
+
usageService.forgetSession(session.id);
|
|
211
|
+
titleApplicator.forgetSession(session.id);
|
|
212
|
+
titleRefresh.cancel(session.id);
|
|
213
|
+
const dateKey = state.sessionDateMap[session.id] ||
|
|
214
|
+
dateKeyFromTimestamp(session.time.created);
|
|
215
|
+
delete state.sessions[session.id];
|
|
216
|
+
delete state.sessionDateMap[session.id];
|
|
608
217
|
scheduleSave();
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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();
|
|
218
|
+
await deleteSessionFromDayChunk(statePath, session.id, dateKey).catch(swallow('deleteSessionFromDayChunk'));
|
|
219
|
+
if (config.sidebar.includeChildren && session.parentID) {
|
|
220
|
+
titleRefresh.schedule(session.parentID, 0);
|
|
626
221
|
}
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
};
|
|
222
|
+
},
|
|
223
|
+
onMessageRemoved: async (sessionID) => {
|
|
224
|
+
usageService.markForceRescan(sessionID);
|
|
225
|
+
titleRefresh.schedule(sessionID);
|
|
226
|
+
},
|
|
227
|
+
onAssistantMessageCompleted: async (message) => {
|
|
228
|
+
usageService.markSessionDirty(message.sessionID);
|
|
229
|
+
titleRefresh.schedule(message.sessionID);
|
|
230
|
+
},
|
|
231
|
+
});
|
|
638
232
|
return {
|
|
639
233
|
event: async ({ event }) => {
|
|
640
234
|
try {
|
|
641
|
-
await
|
|
235
|
+
await dispatchEvent(event);
|
|
642
236
|
}
|
|
643
237
|
catch (error) {
|
|
644
238
|
debug(`event handler failed: ${String(error)}`);
|
|
645
239
|
}
|
|
646
240
|
},
|
|
647
|
-
tool: {
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
},
|
|
241
|
+
tool: createQuotaSidebarTools({
|
|
242
|
+
getTitleEnabled: () => state.titleEnabled,
|
|
243
|
+
setTitleEnabled: (enabled) => {
|
|
244
|
+
state.titleEnabled = enabled;
|
|
245
|
+
},
|
|
246
|
+
scheduleSave,
|
|
247
|
+
refreshSessionTitle: (sessionID, delay) => titleRefresh.schedule(sessionID, delay ?? 250),
|
|
248
|
+
restoreAllVisibleTitles,
|
|
249
|
+
showToast,
|
|
250
|
+
summarizeForTool,
|
|
251
|
+
getQuotaSnapshots,
|
|
252
|
+
renderMarkdownReport,
|
|
253
|
+
renderToastMessage,
|
|
254
|
+
config,
|
|
255
|
+
}),
|
|
697
256
|
};
|
|
698
257
|
}
|
|
699
258
|
export default QuotaSidebarPlugin;
|