@leo000001/opencode-quota-sidebar 1.0.2 → 1.0.3
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 +17 -4
- 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/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
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { TtlValueCache } from './cache.js';
|
|
2
|
+
import { calcEquivalentApiCostForMessage, canonicalApiCostProviderID, modelCostKey, parseModelCostRates, SUBSCRIPTION_API_COST_PROVIDERS, } from './cost.js';
|
|
3
|
+
import { dateKeyFromTimestamp, scanSessionsByCreatedRange } from './storage.js';
|
|
4
|
+
import { periodStart } from './period.js';
|
|
5
|
+
import { debug, isRecord, mapConcurrent, swallow } from './helpers.js';
|
|
6
|
+
import { emptyUsageSummary, fromCachedSessionUsage, mergeUsage, summarizeMessagesIncremental, toCachedSessionUsage, } from './usage.js';
|
|
7
|
+
export function createUsageService(deps) {
|
|
8
|
+
const forceRescanSessions = new Set();
|
|
9
|
+
const dirtyGeneration = new Map();
|
|
10
|
+
const cleanGeneration = new Map();
|
|
11
|
+
const bumpDirty = (sessionID) => {
|
|
12
|
+
dirtyGeneration.set(sessionID, (dirtyGeneration.get(sessionID) || 0) + 1);
|
|
13
|
+
};
|
|
14
|
+
const isDirty = (sessionID) => {
|
|
15
|
+
return ((dirtyGeneration.get(sessionID) || 0) !==
|
|
16
|
+
(cleanGeneration.get(sessionID) || 0));
|
|
17
|
+
};
|
|
18
|
+
// Serialize per-session usage aggregation to avoid redundant message fetches
|
|
19
|
+
// and cursor races when both a child session and its parent (includeChildren)
|
|
20
|
+
// are refreshed concurrently.
|
|
21
|
+
//
|
|
22
|
+
// Track the generation the promise corresponds to; if new messages arrive
|
|
23
|
+
// (generation bumps), callers should not reuse a stale in-flight computation.
|
|
24
|
+
const usageInFlight = new Map();
|
|
25
|
+
const modelCostCache = new TtlValueCache();
|
|
26
|
+
const missingApiCostRateKeys = new Set();
|
|
27
|
+
const getModelCostMap = async () => {
|
|
28
|
+
const cached = modelCostCache.get();
|
|
29
|
+
if (cached)
|
|
30
|
+
return cached;
|
|
31
|
+
const providerClient = deps.client;
|
|
32
|
+
if (!providerClient.provider?.list) {
|
|
33
|
+
return modelCostCache.set({}, 30_000);
|
|
34
|
+
}
|
|
35
|
+
const response = await providerClient.provider
|
|
36
|
+
.list({
|
|
37
|
+
query: { directory: deps.directory },
|
|
38
|
+
throwOnError: true,
|
|
39
|
+
})
|
|
40
|
+
.catch(swallow('getModelCostMap'));
|
|
41
|
+
const all = response &&
|
|
42
|
+
typeof response === 'object' &&
|
|
43
|
+
'data' in response &&
|
|
44
|
+
isRecord(response.data) &&
|
|
45
|
+
Array.isArray(response.data.all)
|
|
46
|
+
? response.data.all
|
|
47
|
+
: [];
|
|
48
|
+
const map = all.reduce((acc, provider) => {
|
|
49
|
+
if (!isRecord(provider))
|
|
50
|
+
return acc;
|
|
51
|
+
const providerID = typeof provider.id === 'string'
|
|
52
|
+
? canonicalApiCostProviderID(provider.id)
|
|
53
|
+
: undefined;
|
|
54
|
+
if (!providerID)
|
|
55
|
+
return acc;
|
|
56
|
+
if (!SUBSCRIPTION_API_COST_PROVIDERS.has(providerID))
|
|
57
|
+
return acc;
|
|
58
|
+
const models = provider.models;
|
|
59
|
+
if (!isRecord(models))
|
|
60
|
+
return acc;
|
|
61
|
+
for (const [modelKey, modelValue] of Object.entries(models)) {
|
|
62
|
+
if (!isRecord(modelValue))
|
|
63
|
+
continue;
|
|
64
|
+
const rates = parseModelCostRates(modelValue.cost);
|
|
65
|
+
if (!rates)
|
|
66
|
+
continue;
|
|
67
|
+
const modelID = typeof modelValue.id === 'string' ? modelValue.id : modelKey;
|
|
68
|
+
acc[modelCostKey(providerID, modelID)] = rates;
|
|
69
|
+
if (modelKey !== modelID) {
|
|
70
|
+
acc[modelCostKey(providerID, modelKey)] = rates;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return acc;
|
|
74
|
+
}, {});
|
|
75
|
+
return modelCostCache.set(map, Math.max(30_000, deps.config.quota.refreshMs));
|
|
76
|
+
};
|
|
77
|
+
const calcEquivalentApiCost = (message, modelCostMap) => {
|
|
78
|
+
const providerID = canonicalApiCostProviderID(message.providerID);
|
|
79
|
+
if (!SUBSCRIPTION_API_COST_PROVIDERS.has(providerID))
|
|
80
|
+
return 0;
|
|
81
|
+
const rates = modelCostMap[modelCostKey(providerID, message.modelID)];
|
|
82
|
+
if (!rates) {
|
|
83
|
+
const key = modelCostKey(providerID, message.modelID);
|
|
84
|
+
if (!missingApiCostRateKeys.has(key)) {
|
|
85
|
+
missingApiCostRateKeys.add(key);
|
|
86
|
+
debug(`apiCost skipped: no model price for ${key}`);
|
|
87
|
+
}
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
return calcEquivalentApiCostForMessage(message, rates);
|
|
91
|
+
};
|
|
92
|
+
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
93
|
+
const decodeTokens = (value) => {
|
|
94
|
+
if (!isRecord(value))
|
|
95
|
+
return undefined;
|
|
96
|
+
if (!isFiniteNumber(value.input))
|
|
97
|
+
return undefined;
|
|
98
|
+
if (!isFiniteNumber(value.output))
|
|
99
|
+
return undefined;
|
|
100
|
+
const reasoning = isFiniteNumber(value.reasoning) ? value.reasoning : 0;
|
|
101
|
+
const cacheRaw = isRecord(value.cache) ? value.cache : {};
|
|
102
|
+
const read = isFiniteNumber(cacheRaw.read) ? cacheRaw.read : 0;
|
|
103
|
+
const write = isFiniteNumber(cacheRaw.write) ? cacheRaw.write : 0;
|
|
104
|
+
return {
|
|
105
|
+
input: value.input,
|
|
106
|
+
output: value.output,
|
|
107
|
+
reasoning,
|
|
108
|
+
cache: { read, write },
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
const decodeMessageInfo = (value) => {
|
|
112
|
+
if (!isRecord(value))
|
|
113
|
+
return undefined;
|
|
114
|
+
if (typeof value.id !== 'string')
|
|
115
|
+
return undefined;
|
|
116
|
+
if (typeof value.sessionID !== 'string')
|
|
117
|
+
return undefined;
|
|
118
|
+
if (typeof value.role !== 'string')
|
|
119
|
+
return undefined;
|
|
120
|
+
if (typeof value.providerID !== 'string')
|
|
121
|
+
return undefined;
|
|
122
|
+
if (typeof value.modelID !== 'string')
|
|
123
|
+
return undefined;
|
|
124
|
+
if (!isRecord(value.time))
|
|
125
|
+
return undefined;
|
|
126
|
+
if (!isFiniteNumber(value.time.created))
|
|
127
|
+
return undefined;
|
|
128
|
+
if (value.time.completed !== undefined &&
|
|
129
|
+
!isFiniteNumber(value.time.completed)) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
const tokens = decodeTokens(value.tokens);
|
|
133
|
+
if (!tokens)
|
|
134
|
+
return undefined;
|
|
135
|
+
// Normalize token fields to a stable shape (some providers/SDK versions may
|
|
136
|
+
// omit reasoning/cache.write; treat them as 0).
|
|
137
|
+
return {
|
|
138
|
+
...value,
|
|
139
|
+
time: {
|
|
140
|
+
created: value.time.created,
|
|
141
|
+
completed: value.time.completed,
|
|
142
|
+
},
|
|
143
|
+
tokens,
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
const decodeMessageEntry = (value) => {
|
|
147
|
+
if (!isRecord(value))
|
|
148
|
+
return undefined;
|
|
149
|
+
const decoded = decodeMessageInfo(value.info);
|
|
150
|
+
if (!decoded)
|
|
151
|
+
return undefined;
|
|
152
|
+
return { info: decoded };
|
|
153
|
+
};
|
|
154
|
+
const decodeMessageEntries = (value) => {
|
|
155
|
+
if (!Array.isArray(value))
|
|
156
|
+
return undefined;
|
|
157
|
+
const decoded = value
|
|
158
|
+
.map((item) => decodeMessageEntry(item))
|
|
159
|
+
.filter((item) => Boolean(item));
|
|
160
|
+
if (decoded.length > 0 && decoded.length < value.length) {
|
|
161
|
+
debug(`message entries partially decoded: kept ${decoded.length}/${value.length}`);
|
|
162
|
+
}
|
|
163
|
+
// If the API returned entries but none match the expected shape,
|
|
164
|
+
// treat it as a load failure so we don't silently undercount.
|
|
165
|
+
if (decoded.length === 0 && value.length > 0)
|
|
166
|
+
return undefined;
|
|
167
|
+
return decoded;
|
|
168
|
+
};
|
|
169
|
+
const loadSessionEntries = async (sessionID) => {
|
|
170
|
+
const response = await deps.client.session
|
|
171
|
+
.messages({
|
|
172
|
+
path: { id: sessionID },
|
|
173
|
+
query: { directory: deps.directory },
|
|
174
|
+
throwOnError: true,
|
|
175
|
+
})
|
|
176
|
+
.catch(swallow('loadSessionEntries'));
|
|
177
|
+
if (!response)
|
|
178
|
+
return undefined;
|
|
179
|
+
const data = response.data;
|
|
180
|
+
return decodeMessageEntries(data);
|
|
181
|
+
};
|
|
182
|
+
const persistSessionUsage = (sessionID, usage) => {
|
|
183
|
+
const sessionState = deps.state.sessions[sessionID];
|
|
184
|
+
if (!sessionState)
|
|
185
|
+
return;
|
|
186
|
+
sessionState.usage = usage;
|
|
187
|
+
const dateKey = deps.state.sessionDateMap[sessionID] ||
|
|
188
|
+
dateKeyFromTimestamp(sessionState.createdAt);
|
|
189
|
+
deps.state.sessionDateMap[sessionID] = dateKey;
|
|
190
|
+
deps.persistence.markDirty(dateKey);
|
|
191
|
+
};
|
|
192
|
+
const summarizeSessionUsage = async (sessionID, generationAtStart) => {
|
|
193
|
+
const entries = await loadSessionEntries(sessionID);
|
|
194
|
+
const sessionState = deps.state.sessions[sessionID];
|
|
195
|
+
// If we can't load messages (transient API failure), fall back to cached
|
|
196
|
+
// usage if available and avoid mutating cursor/dirty state.
|
|
197
|
+
if (!entries) {
|
|
198
|
+
if (sessionState?.usage) {
|
|
199
|
+
return {
|
|
200
|
+
usage: fromCachedSessionUsage(sessionState.usage, 1),
|
|
201
|
+
persist: false,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
const empty = emptyUsageSummary();
|
|
205
|
+
empty.sessionCount = 1;
|
|
206
|
+
return { usage: empty, persist: false };
|
|
207
|
+
}
|
|
208
|
+
const modelCostMap = await getModelCostMap();
|
|
209
|
+
const forceRescan = forceRescanSessions.has(sessionID);
|
|
210
|
+
if (forceRescan)
|
|
211
|
+
forceRescanSessions.delete(sessionID);
|
|
212
|
+
const { usage, cursor } = summarizeMessagesIncremental(entries, sessionState?.usage, sessionState?.cursor, forceRescan, {
|
|
213
|
+
calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
|
|
214
|
+
});
|
|
215
|
+
usage.sessionCount = 1;
|
|
216
|
+
// Update cursor in state
|
|
217
|
+
if (sessionState) {
|
|
218
|
+
sessionState.cursor = cursor;
|
|
219
|
+
}
|
|
220
|
+
if ((dirtyGeneration.get(sessionID) || 0) === generationAtStart) {
|
|
221
|
+
cleanGeneration.set(sessionID, generationAtStart);
|
|
222
|
+
}
|
|
223
|
+
return { usage, persist: true };
|
|
224
|
+
};
|
|
225
|
+
const summarizeSessionUsageLocked = async (sessionID) => {
|
|
226
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
227
|
+
const generationAtStart = dirtyGeneration.get(sessionID) || 0;
|
|
228
|
+
const existing = usageInFlight.get(sessionID);
|
|
229
|
+
if (existing && existing.generation === generationAtStart) {
|
|
230
|
+
const result = await existing.promise;
|
|
231
|
+
if ((dirtyGeneration.get(sessionID) || 0) !== generationAtStart)
|
|
232
|
+
continue;
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
const promise = summarizeSessionUsage(sessionID, generationAtStart);
|
|
236
|
+
const entry = { generation: generationAtStart, promise };
|
|
237
|
+
promise.finally(() => {
|
|
238
|
+
const current = usageInFlight.get(sessionID);
|
|
239
|
+
if (current?.promise === promise)
|
|
240
|
+
usageInFlight.delete(sessionID);
|
|
241
|
+
});
|
|
242
|
+
usageInFlight.set(sessionID, entry);
|
|
243
|
+
const result = await promise;
|
|
244
|
+
if ((dirtyGeneration.get(sessionID) || 0) !== generationAtStart)
|
|
245
|
+
continue;
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
const generationAtStart = dirtyGeneration.get(sessionID) || 0;
|
|
249
|
+
return summarizeSessionUsage(sessionID, generationAtStart);
|
|
250
|
+
};
|
|
251
|
+
const summarizeSessionUsageForDisplay = async (sessionID, includeChildren) => {
|
|
252
|
+
const root = await summarizeSessionUsageLocked(sessionID);
|
|
253
|
+
const usage = root.usage;
|
|
254
|
+
if (root.persist) {
|
|
255
|
+
persistSessionUsage(sessionID, toCachedSessionUsage(usage));
|
|
256
|
+
}
|
|
257
|
+
if (!includeChildren)
|
|
258
|
+
return usage;
|
|
259
|
+
const descendantIDs = await deps.descendantsResolver.listDescendantSessionIDs(sessionID, {
|
|
260
|
+
maxDepth: deps.config.sidebar.childrenMaxDepth,
|
|
261
|
+
maxSessions: deps.config.sidebar.childrenMaxSessions,
|
|
262
|
+
concurrency: deps.config.sidebar.childrenConcurrency,
|
|
263
|
+
});
|
|
264
|
+
if (descendantIDs.length === 0)
|
|
265
|
+
return usage;
|
|
266
|
+
const merged = emptyUsageSummary();
|
|
267
|
+
mergeUsage(merged, usage);
|
|
268
|
+
const needsFetch = [];
|
|
269
|
+
for (const childID of descendantIDs) {
|
|
270
|
+
const cached = deps.state.sessions[childID]?.usage;
|
|
271
|
+
if (cached && !isDirty(childID)) {
|
|
272
|
+
mergeUsage(merged, fromCachedSessionUsage(cached, 1));
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
needsFetch.push(childID);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (needsFetch.length > 0) {
|
|
279
|
+
const fetched = await mapConcurrent(needsFetch, deps.config.sidebar.childrenConcurrency, async (childID) => {
|
|
280
|
+
const child = await summarizeSessionUsageLocked(childID);
|
|
281
|
+
if (child.persist) {
|
|
282
|
+
persistSessionUsage(childID, toCachedSessionUsage(child.usage));
|
|
283
|
+
}
|
|
284
|
+
return child.usage;
|
|
285
|
+
});
|
|
286
|
+
for (const childUsage of fetched) {
|
|
287
|
+
mergeUsage(merged, childUsage);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return merged;
|
|
291
|
+
};
|
|
292
|
+
const RANGE_USAGE_CONCURRENCY = 5;
|
|
293
|
+
const summarizeRangeUsage = async (period) => {
|
|
294
|
+
const startAt = periodStart(period);
|
|
295
|
+
await deps.persistence.flushSave();
|
|
296
|
+
const sessions = await scanSessionsByCreatedRange(deps.statePath, startAt, Date.now(), deps.state);
|
|
297
|
+
const usage = emptyUsageSummary();
|
|
298
|
+
usage.sessionCount = sessions.length;
|
|
299
|
+
const modelCostMap = await getModelCostMap();
|
|
300
|
+
const hasPricing = Object.keys(modelCostMap).length > 0;
|
|
301
|
+
const hasAnySubscriptionProvider = (cached) => {
|
|
302
|
+
const providerIDs = Object.keys(cached.providers);
|
|
303
|
+
// Back-compat: older cached chunks may have empty providers.
|
|
304
|
+
// In that case, allow recompute so we can persist apiCost.
|
|
305
|
+
if (providerIDs.length === 0)
|
|
306
|
+
return true;
|
|
307
|
+
return providerIDs.some((providerID) => {
|
|
308
|
+
const canonical = canonicalApiCostProviderID(providerID);
|
|
309
|
+
return SUBSCRIPTION_API_COST_PROVIDERS.has(canonical);
|
|
310
|
+
});
|
|
311
|
+
};
|
|
312
|
+
const shouldRecomputeApiCost = (cached) => {
|
|
313
|
+
if (!hasPricing)
|
|
314
|
+
return false;
|
|
315
|
+
if (cached.assistantMessages <= 0)
|
|
316
|
+
return false;
|
|
317
|
+
if (cached.apiCost > 0)
|
|
318
|
+
return false;
|
|
319
|
+
if (cached.total <= 0)
|
|
320
|
+
return false;
|
|
321
|
+
if (!hasAnySubscriptionProvider(cached))
|
|
322
|
+
return false;
|
|
323
|
+
return true;
|
|
324
|
+
};
|
|
325
|
+
const needsFetch = [];
|
|
326
|
+
for (const session of sessions) {
|
|
327
|
+
if (session.state.usage) {
|
|
328
|
+
if (shouldRecomputeApiCost(session.state.usage)) {
|
|
329
|
+
needsFetch.push(session);
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
mergeUsage(usage, fromCachedSessionUsage(session.state.usage, 0));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
needsFetch.push(session);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (needsFetch.length > 0) {
|
|
340
|
+
const fetched = await mapConcurrent(needsFetch, RANGE_USAGE_CONCURRENCY, async (session) => {
|
|
341
|
+
const entries = await loadSessionEntries(session.sessionID);
|
|
342
|
+
if (!entries) {
|
|
343
|
+
if (session.state.usage) {
|
|
344
|
+
return {
|
|
345
|
+
sessionID: session.sessionID,
|
|
346
|
+
computed: fromCachedSessionUsage(session.state.usage, 1),
|
|
347
|
+
persist: false,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
const empty = emptyUsageSummary();
|
|
351
|
+
empty.sessionCount = 1;
|
|
352
|
+
return {
|
|
353
|
+
sessionID: session.sessionID,
|
|
354
|
+
computed: empty,
|
|
355
|
+
persist: false,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
const { usage: computed } = summarizeMessagesIncremental(entries, undefined, undefined, true, {
|
|
359
|
+
calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
|
|
360
|
+
});
|
|
361
|
+
return { sessionID: session.sessionID, computed, persist: true };
|
|
362
|
+
});
|
|
363
|
+
let dirty = false;
|
|
364
|
+
for (const { sessionID, computed, persist } of fetched) {
|
|
365
|
+
mergeUsage(usage, { ...computed, sessionCount: 0 });
|
|
366
|
+
const memoryState = deps.state.sessions[sessionID];
|
|
367
|
+
if (persist && memoryState) {
|
|
368
|
+
memoryState.usage = toCachedSessionUsage(computed);
|
|
369
|
+
const dateKey = deps.state.sessionDateMap[sessionID] ||
|
|
370
|
+
dateKeyFromTimestamp(memoryState.createdAt);
|
|
371
|
+
deps.state.sessionDateMap[sessionID] = dateKey;
|
|
372
|
+
deps.persistence.markDirty(dateKey);
|
|
373
|
+
dirty = true;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (dirty)
|
|
377
|
+
deps.persistence.scheduleSave();
|
|
378
|
+
}
|
|
379
|
+
return usage;
|
|
380
|
+
};
|
|
381
|
+
const summarizeForTool = async (period, sessionID, includeChildren) => {
|
|
382
|
+
if (period === 'session') {
|
|
383
|
+
return summarizeSessionUsageForDisplay(sessionID, includeChildren);
|
|
384
|
+
}
|
|
385
|
+
return summarizeRangeUsage(period);
|
|
386
|
+
};
|
|
387
|
+
const markSessionDirty = (sessionID) => {
|
|
388
|
+
bumpDirty(sessionID);
|
|
389
|
+
};
|
|
390
|
+
const markForceRescan = (sessionID) => {
|
|
391
|
+
forceRescanSessions.add(sessionID);
|
|
392
|
+
bumpDirty(sessionID);
|
|
393
|
+
const sessionState = deps.state.sessions[sessionID];
|
|
394
|
+
if (sessionState) {
|
|
395
|
+
sessionState.usage = undefined;
|
|
396
|
+
sessionState.cursor = undefined;
|
|
397
|
+
const dateKey = deps.state.sessionDateMap[sessionID] ||
|
|
398
|
+
dateKeyFromTimestamp(sessionState.createdAt);
|
|
399
|
+
deps.state.sessionDateMap[sessionID] = dateKey;
|
|
400
|
+
deps.persistence.markDirty(dateKey);
|
|
401
|
+
deps.persistence.scheduleSave();
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
const forgetSession = (sessionID) => {
|
|
405
|
+
forceRescanSessions.delete(sessionID);
|
|
406
|
+
dirtyGeneration.delete(sessionID);
|
|
407
|
+
cleanGeneration.delete(sessionID);
|
|
408
|
+
usageInFlight.delete(sessionID);
|
|
409
|
+
};
|
|
410
|
+
return {
|
|
411
|
+
summarizeSessionUsageForDisplay,
|
|
412
|
+
summarizeForTool,
|
|
413
|
+
markSessionDirty,
|
|
414
|
+
markForceRescan,
|
|
415
|
+
forgetSession,
|
|
416
|
+
};
|
|
417
|
+
}
|
package/package.json
CHANGED