@leo000001/opencode-quota-sidebar 2.0.0 → 2.0.2
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 +26 -19
- package/dist/cost.js +2 -1
- package/dist/format.js +155 -52
- package/dist/index.js +77 -4
- package/dist/persistence.js +15 -1
- package/dist/quota.js +3 -5
- package/dist/quota_service.js +194 -29
- package/dist/storage.d.ts +5 -0
- package/dist/storage.js +74 -9
- package/dist/storage_chunks.js +20 -9
- package/dist/storage_parse.js +4 -1
- package/dist/title.js +37 -15
- package/dist/title_apply.d.ts +21 -3
- package/dist/title_apply.js +109 -23
- package/dist/title_refresh.d.ts +4 -0
- package/dist/title_refresh.js +35 -1
- package/dist/tools.d.ts +22 -1
- package/dist/tools.js +60 -14
- package/dist/types.d.ts +27 -1
- package/dist/usage.d.ts +14 -6
- package/dist/usage.js +78 -13
- package/dist/usage_service.js +159 -55
- package/package.json +1 -1
package/dist/quota_service.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { TtlValueCache } from './cache.js';
|
|
2
3
|
import { isRecord, swallow } from './helpers.js';
|
|
3
4
|
import { listDefaultQuotaProviderIDs, loadAuthMap, quotaSort } from './quota.js';
|
|
@@ -10,12 +11,43 @@ export function createQuotaService(deps) {
|
|
|
10
11
|
const authCache = new TtlValueCache();
|
|
11
12
|
const providerOptionsCache = new TtlValueCache();
|
|
12
13
|
const inFlight = new Map();
|
|
14
|
+
let lastSuccessfulProviderOptionsMap = {};
|
|
15
|
+
const authFingerprint = (auth) => {
|
|
16
|
+
if (!auth || typeof auth !== 'object')
|
|
17
|
+
return undefined;
|
|
18
|
+
const stable = JSON.stringify(Object.keys(auth)
|
|
19
|
+
.sort()
|
|
20
|
+
.reduce((acc, key) => {
|
|
21
|
+
const value = auth[key];
|
|
22
|
+
if (value !== undefined)
|
|
23
|
+
acc[key] = value;
|
|
24
|
+
return acc;
|
|
25
|
+
}, {}));
|
|
26
|
+
return createHash('sha256').update(stable).digest('hex').slice(0, 12);
|
|
27
|
+
};
|
|
28
|
+
const providerOptionsFingerprint = (providerOptions) => {
|
|
29
|
+
if (!providerOptions)
|
|
30
|
+
return undefined;
|
|
31
|
+
const stable = JSON.stringify(Object.keys(providerOptions)
|
|
32
|
+
.sort()
|
|
33
|
+
.reduce((acc, key) => {
|
|
34
|
+
if (key === 'baseURL')
|
|
35
|
+
return acc;
|
|
36
|
+
const value = providerOptions[key];
|
|
37
|
+
if (value !== undefined)
|
|
38
|
+
acc[key] = value;
|
|
39
|
+
return acc;
|
|
40
|
+
}, {}));
|
|
41
|
+
if (stable === '{}')
|
|
42
|
+
return undefined;
|
|
43
|
+
return createHash('sha256').update(stable).digest('hex').slice(0, 12);
|
|
44
|
+
};
|
|
13
45
|
const getAuthMap = async () => {
|
|
14
46
|
const cached = authCache.get();
|
|
15
47
|
if (cached)
|
|
16
48
|
return cached;
|
|
17
49
|
const value = await loadAuthMap(deps.authPath);
|
|
18
|
-
return authCache.set(value,
|
|
50
|
+
return authCache.set(value, 5_000);
|
|
19
51
|
};
|
|
20
52
|
const getProviderOptionsMap = async () => {
|
|
21
53
|
const cached = providerOptionsCache.get();
|
|
@@ -23,27 +55,131 @@ export function createQuotaService(deps) {
|
|
|
23
55
|
return cached;
|
|
24
56
|
const client = deps.client;
|
|
25
57
|
if (!client.config?.providers && !client.provider?.list) {
|
|
26
|
-
return providerOptionsCache.set({},
|
|
58
|
+
return providerOptionsCache.set({}, 5_000);
|
|
27
59
|
}
|
|
28
60
|
// Newer runtimes expose config.providers; older clients may only expose
|
|
29
61
|
// provider.list with a slightly different response shape.
|
|
30
|
-
|
|
31
|
-
|
|
62
|
+
let response;
|
|
63
|
+
let fromConfigProviders = false;
|
|
64
|
+
if (client.config?.providers) {
|
|
65
|
+
fromConfigProviders = true;
|
|
66
|
+
response = await client.config
|
|
67
|
+
.providers({
|
|
32
68
|
query: { directory: deps.directory },
|
|
33
69
|
throwOnError: true,
|
|
34
70
|
})
|
|
35
|
-
|
|
71
|
+
.catch(swallow('getProviderOptionsMap:configProviders'));
|
|
72
|
+
}
|
|
73
|
+
if (!response && client.provider?.list) {
|
|
74
|
+
response = await client.provider
|
|
75
|
+
.list({
|
|
36
76
|
query: { directory: deps.directory },
|
|
37
77
|
throwOnError: true,
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
78
|
+
})
|
|
79
|
+
.catch(swallow('getProviderOptionsMap:providerList'));
|
|
80
|
+
}
|
|
81
|
+
const data = isRecord(response) && Object.prototype.hasOwnProperty.call(response, 'data')
|
|
82
|
+
? response.data
|
|
83
|
+
: undefined;
|
|
84
|
+
if (!response || data === undefined) {
|
|
85
|
+
if (client.provider?.list && fromConfigProviders) {
|
|
86
|
+
response = await client.provider
|
|
87
|
+
.list({
|
|
88
|
+
query: { directory: deps.directory },
|
|
89
|
+
throwOnError: true,
|
|
90
|
+
})
|
|
91
|
+
.catch(swallow('getProviderOptionsMap:providerListNoDataFallback'));
|
|
92
|
+
const fallbackData = isRecord(response) && Object.prototype.hasOwnProperty.call(response, 'data')
|
|
93
|
+
? response.data
|
|
94
|
+
: undefined;
|
|
95
|
+
const fallbackRecord = isRecord(fallbackData) ? fallbackData : undefined;
|
|
96
|
+
const fallbackList = Array.isArray(fallbackRecord?.providers)
|
|
97
|
+
? fallbackRecord.providers
|
|
98
|
+
: Array.isArray(fallbackRecord?.all)
|
|
99
|
+
? fallbackRecord.all
|
|
100
|
+
: Array.isArray(fallbackData)
|
|
101
|
+
? fallbackData
|
|
102
|
+
: undefined;
|
|
103
|
+
const map = Array.isArray(fallbackList)
|
|
104
|
+
? fallbackList.reduce((acc, item) => {
|
|
105
|
+
if (!item || typeof item !== 'object')
|
|
106
|
+
return acc;
|
|
107
|
+
const record = item;
|
|
108
|
+
const id = record.id;
|
|
109
|
+
const options = record.options;
|
|
110
|
+
if (typeof id !== 'string')
|
|
111
|
+
return acc;
|
|
112
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
113
|
+
acc[id] = {};
|
|
114
|
+
return acc;
|
|
115
|
+
}
|
|
116
|
+
acc[id] = options;
|
|
117
|
+
return acc;
|
|
118
|
+
}, {})
|
|
119
|
+
: {};
|
|
120
|
+
if (Object.keys(map).length > 0) {
|
|
121
|
+
lastSuccessfulProviderOptionsMap = map;
|
|
122
|
+
return providerOptionsCache.set(map, 5_000);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return Object.keys(lastSuccessfulProviderOptionsMap).length > 0
|
|
126
|
+
? lastSuccessfulProviderOptionsMap
|
|
127
|
+
: {};
|
|
128
|
+
}
|
|
129
|
+
const dataRecord = isRecord(data) ? data : undefined;
|
|
130
|
+
const list = Array.isArray(dataRecord?.providers)
|
|
131
|
+
? dataRecord.providers
|
|
132
|
+
: Array.isArray(dataRecord?.all)
|
|
133
|
+
? dataRecord.all
|
|
44
134
|
: Array.isArray(data)
|
|
45
135
|
? data
|
|
46
136
|
: undefined;
|
|
137
|
+
if (!list && fromConfigProviders && client.provider?.list) {
|
|
138
|
+
response = await client.provider
|
|
139
|
+
.list({
|
|
140
|
+
query: { directory: deps.directory },
|
|
141
|
+
throwOnError: true,
|
|
142
|
+
})
|
|
143
|
+
.catch(swallow('getProviderOptionsMap:providerListFallback'));
|
|
144
|
+
const fallbackData = isRecord(response) && Object.prototype.hasOwnProperty.call(response, 'data')
|
|
145
|
+
? response.data
|
|
146
|
+
: undefined;
|
|
147
|
+
const fallbackRecord = isRecord(fallbackData) ? fallbackData : undefined;
|
|
148
|
+
const fallbackList = Array.isArray(fallbackRecord?.providers)
|
|
149
|
+
? fallbackRecord.providers
|
|
150
|
+
: Array.isArray(fallbackRecord?.all)
|
|
151
|
+
? fallbackRecord.all
|
|
152
|
+
: Array.isArray(fallbackData)
|
|
153
|
+
? fallbackData
|
|
154
|
+
: undefined;
|
|
155
|
+
const map = Array.isArray(fallbackList)
|
|
156
|
+
? fallbackList.reduce((acc, item) => {
|
|
157
|
+
if (!item || typeof item !== 'object')
|
|
158
|
+
return acc;
|
|
159
|
+
const record = item;
|
|
160
|
+
const id = record.id;
|
|
161
|
+
const options = record.options;
|
|
162
|
+
if (typeof id !== 'string')
|
|
163
|
+
return acc;
|
|
164
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
165
|
+
acc[id] = {};
|
|
166
|
+
return acc;
|
|
167
|
+
}
|
|
168
|
+
acc[id] = options;
|
|
169
|
+
return acc;
|
|
170
|
+
}, {})
|
|
171
|
+
: {};
|
|
172
|
+
if (Object.keys(map).length > 0) {
|
|
173
|
+
lastSuccessfulProviderOptionsMap = map;
|
|
174
|
+
return providerOptionsCache.set(map, 5_000);
|
|
175
|
+
}
|
|
176
|
+
if (!Array.isArray(fallbackList)) {
|
|
177
|
+
return Object.keys(lastSuccessfulProviderOptionsMap).length > 0
|
|
178
|
+
? lastSuccessfulProviderOptionsMap
|
|
179
|
+
: {};
|
|
180
|
+
}
|
|
181
|
+
return providerOptionsCache.set(map, 5_000);
|
|
182
|
+
}
|
|
47
183
|
const map = Array.isArray(list)
|
|
48
184
|
? list.reduce((acc, item) => {
|
|
49
185
|
if (!item || typeof item !== 'object')
|
|
@@ -63,7 +199,16 @@ export function createQuotaService(deps) {
|
|
|
63
199
|
return acc;
|
|
64
200
|
}, {})
|
|
65
201
|
: {};
|
|
66
|
-
|
|
202
|
+
if (Object.keys(map).length > 0) {
|
|
203
|
+
lastSuccessfulProviderOptionsMap = map;
|
|
204
|
+
return providerOptionsCache.set(map, 5_000);
|
|
205
|
+
}
|
|
206
|
+
if (!Array.isArray(list)) {
|
|
207
|
+
return Object.keys(lastSuccessfulProviderOptionsMap).length > 0
|
|
208
|
+
? lastSuccessfulProviderOptionsMap
|
|
209
|
+
: providerOptionsCache.set(map, 5_000);
|
|
210
|
+
}
|
|
211
|
+
return providerOptionsCache.set(map, 5_000);
|
|
67
212
|
};
|
|
68
213
|
const isValidQuotaCache = (snapshot) => {
|
|
69
214
|
// Guard against stale RightCode cache entries from pre-daily format.
|
|
@@ -166,14 +311,6 @@ export function createQuotaService(deps) {
|
|
|
166
311
|
? directCandidates
|
|
167
312
|
: defaultCandidates;
|
|
168
313
|
const matchedCandidates = rawCandidates.filter((candidate) => Boolean(deps.quotaRuntime.resolveQuotaAdapter(candidate.providerID, candidate.providerOptions)));
|
|
169
|
-
const dedupedCandidates = Array.from(matchedCandidates
|
|
170
|
-
.reduce((acc, candidate) => {
|
|
171
|
-
const key = deps.quotaRuntime.quotaCacheKey(candidate.providerID, candidate.providerOptions);
|
|
172
|
-
if (!acc.has(key))
|
|
173
|
-
acc.set(key, candidate);
|
|
174
|
-
return acc;
|
|
175
|
-
}, new Map())
|
|
176
|
-
.values());
|
|
177
314
|
function authScopeFor(providerID, providerOptions) {
|
|
178
315
|
const adapter = deps.quotaRuntime.resolveQuotaAdapter(providerID, providerOptions);
|
|
179
316
|
const normalized = deps.quotaRuntime.normalizeProviderID(providerID);
|
|
@@ -186,24 +323,48 @@ export function createQuotaService(deps) {
|
|
|
186
323
|
candidates.push(value);
|
|
187
324
|
};
|
|
188
325
|
push(providerID);
|
|
189
|
-
push(normalized);
|
|
190
|
-
push(adapterID);
|
|
191
326
|
if (adapterID === 'github-copilot')
|
|
192
327
|
push('github-copilot-enterprise');
|
|
328
|
+
push(normalized);
|
|
329
|
+
push(adapterID);
|
|
330
|
+
const optionsFingerprint = providerOptionsFingerprint(providerOptions);
|
|
193
331
|
for (const key of candidates) {
|
|
194
332
|
const auth = authMap[key];
|
|
195
333
|
if (!auth)
|
|
196
334
|
continue;
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
typeof auth.accountId === 'string' &&
|
|
200
|
-
|
|
201
|
-
|
|
335
|
+
if (auth.type === 'oauth') {
|
|
336
|
+
const authRecord = auth;
|
|
337
|
+
const identity = (typeof auth.accountId === 'string' && auth.accountId) ||
|
|
338
|
+
(typeof authRecord.login === 'string' && authRecord.login) ||
|
|
339
|
+
(typeof authRecord.userId === 'string' && authRecord.userId);
|
|
340
|
+
if (identity) {
|
|
341
|
+
return optionsFingerprint
|
|
342
|
+
? `${key}@${identity}|options@${optionsFingerprint}`
|
|
343
|
+
: `${key}@${identity}`;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const fingerprint = authFingerprint(auth);
|
|
347
|
+
if (fingerprint) {
|
|
348
|
+
return optionsFingerprint
|
|
349
|
+
? `${key}@${fingerprint}|options@${optionsFingerprint}`
|
|
350
|
+
: `${key}@${fingerprint}`;
|
|
202
351
|
}
|
|
203
|
-
return key;
|
|
352
|
+
return optionsFingerprint ? `${key}|options@${optionsFingerprint}` : key;
|
|
353
|
+
}
|
|
354
|
+
if (optionsFingerprint) {
|
|
355
|
+
return `options@${optionsFingerprint}`;
|
|
204
356
|
}
|
|
205
357
|
return 'none';
|
|
206
358
|
}
|
|
359
|
+
const dedupedCandidates = Array.from(matchedCandidates
|
|
360
|
+
.reduce((acc, candidate) => {
|
|
361
|
+
const baseKey = deps.quotaRuntime.quotaCacheKey(candidate.providerID, candidate.providerOptions);
|
|
362
|
+
const key = `${baseKey}#${authScopeFor(candidate.providerID, candidate.providerOptions)}`;
|
|
363
|
+
if (!acc.has(key))
|
|
364
|
+
acc.set(key, candidate);
|
|
365
|
+
return acc;
|
|
366
|
+
}, new Map())
|
|
367
|
+
.values());
|
|
207
368
|
let cacheChanged = false;
|
|
208
369
|
const fetchSnapshot = (providerID, providerOptions) => {
|
|
209
370
|
const baseKey = deps.quotaRuntime.quotaCacheKey(providerID, providerOptions);
|
|
@@ -229,7 +390,11 @@ export function createQuotaService(deps) {
|
|
|
229
390
|
body: next,
|
|
230
391
|
throwOnError: true,
|
|
231
392
|
})
|
|
232
|
-
.catch(
|
|
393
|
+
.catch((error) => {
|
|
394
|
+
swallow('getQuotaSnapshots:authSet')(error);
|
|
395
|
+
throw error;
|
|
396
|
+
});
|
|
397
|
+
authCache.clear();
|
|
233
398
|
}, providerOptions)
|
|
234
399
|
.then((latest) => {
|
|
235
400
|
if (!latest)
|
package/dist/storage.d.ts
CHANGED
|
@@ -30,6 +30,11 @@ export declare function scanSessionsByCreatedRange(statePath: string, startAt: n
|
|
|
30
30
|
dateKey: string;
|
|
31
31
|
state: SessionState;
|
|
32
32
|
}[]>;
|
|
33
|
+
export declare function scanAllSessions(statePath: string, memoryState?: QuotaSidebarState): Promise<{
|
|
34
|
+
sessionID: string;
|
|
35
|
+
dateKey: string;
|
|
36
|
+
state: SessionState;
|
|
37
|
+
}[]>;
|
|
33
38
|
/** Best-effort: remove a session entry from its day chunk (if present). */
|
|
34
39
|
export declare function deleteSessionFromDayChunk(statePath: string, sessionID: string, dateKey: string): Promise<boolean>;
|
|
35
40
|
/** Best-effort: persist recomputed usage/cursor for sessions loaded from disk-only chunks. */
|
package/dist/storage.js
CHANGED
|
@@ -40,6 +40,7 @@ export function defaultState() {
|
|
|
40
40
|
titleEnabled: true,
|
|
41
41
|
sessionDateMap: {},
|
|
42
42
|
sessions: {},
|
|
43
|
+
deletedSessionDateMap: {},
|
|
43
44
|
quotaCache: {},
|
|
44
45
|
};
|
|
45
46
|
}
|
|
@@ -128,6 +129,17 @@ async function loadVersion2State(raw, statePath) {
|
|
|
128
129
|
acc[sessionID] = value;
|
|
129
130
|
return acc;
|
|
130
131
|
}, {});
|
|
132
|
+
const deletedSessionDateMapRaw = isRecord(raw.deletedSessionDateMap)
|
|
133
|
+
? raw.deletedSessionDateMap
|
|
134
|
+
: {};
|
|
135
|
+
const deletedSessionDateMap = Object.entries(deletedSessionDateMapRaw).reduce((acc, [sessionID, value]) => {
|
|
136
|
+
if (typeof value !== 'string')
|
|
137
|
+
return acc;
|
|
138
|
+
if (!isDateKey(value))
|
|
139
|
+
return acc;
|
|
140
|
+
acc[sessionID] = value;
|
|
141
|
+
return acc;
|
|
142
|
+
}, {});
|
|
131
143
|
const hadRawSessionDateMapEntries = isRecord(raw.sessionDateMap) && Object.keys(raw.sessionDateMap).length > 0;
|
|
132
144
|
const explicitDateKeys = Array.from(new Set(Object.values(sessionDateMap)));
|
|
133
145
|
// Only discover chunks when sessionDateMap is missing from state.
|
|
@@ -150,6 +162,8 @@ async function loadVersion2State(raw, statePath) {
|
|
|
150
162
|
const sessions = {};
|
|
151
163
|
for (const [dateKey, chunkSessions] of chunks) {
|
|
152
164
|
for (const [sessionID, session] of Object.entries(chunkSessions)) {
|
|
165
|
+
if (deletedSessionDateMap[sessionID])
|
|
166
|
+
continue;
|
|
153
167
|
sessions[sessionID] = session;
|
|
154
168
|
if (!sessionDateMap[sessionID])
|
|
155
169
|
sessionDateMap[sessionID] = dateKey;
|
|
@@ -160,6 +174,7 @@ async function loadVersion2State(raw, statePath) {
|
|
|
160
174
|
titleEnabled,
|
|
161
175
|
sessionDateMap,
|
|
162
176
|
sessions,
|
|
177
|
+
deletedSessionDateMap,
|
|
163
178
|
quotaCache,
|
|
164
179
|
};
|
|
165
180
|
}
|
|
@@ -237,22 +252,31 @@ export async function saveState(statePath, state, options) {
|
|
|
237
252
|
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
238
253
|
if (!skipChunks) {
|
|
239
254
|
const keysToWrite = writeAll
|
|
240
|
-
?
|
|
255
|
+
? Array.from(new Set([
|
|
256
|
+
...Object.keys(sessionsByDate),
|
|
257
|
+
...Object.values(state.deletedSessionDateMap),
|
|
258
|
+
]))
|
|
241
259
|
: Array.from(dirtySet ?? []);
|
|
242
260
|
await Promise.all(keysToWrite
|
|
243
261
|
.map((dateKey) => {
|
|
244
|
-
if (!Object.prototype.hasOwnProperty.call(sessionsByDate, dateKey)) {
|
|
245
|
-
return undefined;
|
|
246
|
-
}
|
|
247
262
|
return withDayChunkWriteLock(dateKey, async () => {
|
|
248
263
|
const memorySessions = sessionsByDate[dateKey] || {};
|
|
264
|
+
const tombstonedIDs = Object.entries(state.deletedSessionDateMap)
|
|
265
|
+
.filter(([, tombstoneDateKey]) => tombstoneDateKey === dateKey)
|
|
266
|
+
.map(([sessionID]) => sessionID);
|
|
249
267
|
const next = writeAll
|
|
250
268
|
? memorySessions
|
|
251
269
|
: {
|
|
252
270
|
...(await readDayChunk(rootPath, dateKey)),
|
|
253
271
|
...memorySessions,
|
|
254
272
|
};
|
|
273
|
+
for (const sessionID of tombstonedIDs) {
|
|
274
|
+
delete next[sessionID];
|
|
275
|
+
}
|
|
255
276
|
await writeDayChunk(rootPath, dateKey, next);
|
|
277
|
+
for (const sessionID of tombstonedIDs) {
|
|
278
|
+
delete state.deletedSessionDateMap[sessionID];
|
|
279
|
+
}
|
|
256
280
|
});
|
|
257
281
|
})
|
|
258
282
|
.filter((promise) => Boolean(promise)));
|
|
@@ -262,6 +286,7 @@ export async function saveState(statePath, state, options) {
|
|
|
262
286
|
version: 2,
|
|
263
287
|
titleEnabled: state.titleEnabled,
|
|
264
288
|
sessionDateMap: state.sessionDateMap,
|
|
289
|
+
deletedSessionDateMap: state.deletedSessionDateMap,
|
|
265
290
|
quotaCache: state.quotaCache,
|
|
266
291
|
}, null, 2)}\n`);
|
|
267
292
|
}
|
|
@@ -292,6 +317,7 @@ export function evictOldSessions(state, retentionDays) {
|
|
|
292
317
|
*/
|
|
293
318
|
export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Date.now(), memoryState) {
|
|
294
319
|
const rootPath = chunkRootPathFromStateFile(statePath);
|
|
320
|
+
const deletedSessionIDs = new Set(Object.keys(memoryState?.deletedSessionDateMap || {}));
|
|
295
321
|
const dateKeys = dateKeysInRange(startAt, endAt);
|
|
296
322
|
if (!dateKeys.length) {
|
|
297
323
|
return [];
|
|
@@ -302,6 +328,8 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
|
|
|
302
328
|
if (memoryState) {
|
|
303
329
|
const dateKeySet = new Set(dateKeys);
|
|
304
330
|
for (const [sessionID, session] of Object.entries(memoryState.sessions)) {
|
|
331
|
+
if (deletedSessionIDs.has(sessionID))
|
|
332
|
+
continue;
|
|
305
333
|
const dk = memoryState.sessionDateMap[sessionID];
|
|
306
334
|
if (!dk || !dateKeySet.has(dk))
|
|
307
335
|
continue;
|
|
@@ -314,11 +342,9 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
|
|
|
314
342
|
}
|
|
315
343
|
}
|
|
316
344
|
}
|
|
317
|
-
// Second pass: read disk chunks for date keys
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
: new Set();
|
|
321
|
-
const diskDateKeys = dateKeys.filter((dk) => !memoryDateKeys.has(dk));
|
|
345
|
+
// Second pass: read disk chunks for all date keys in range, then de-dupe by sessionID.
|
|
346
|
+
// A date can have a mix of in-memory and disk-only sessions.
|
|
347
|
+
const diskDateKeys = [...dateKeys];
|
|
322
348
|
if (diskDateKeys.length > 0) {
|
|
323
349
|
const RANGE_SCAN_CONCURRENCY = 5;
|
|
324
350
|
const chunkEntries = await mapConcurrent(diskDateKeys, RANGE_SCAN_CONCURRENCY, async (dateKey) => {
|
|
@@ -332,6 +358,8 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
|
|
|
332
358
|
for (const entry of chunkEntries.flat()) {
|
|
333
359
|
if (seenSessionIDs.has(entry.sessionID))
|
|
334
360
|
continue;
|
|
361
|
+
if (deletedSessionIDs.has(entry.sessionID))
|
|
362
|
+
continue;
|
|
335
363
|
const createdAt = Number.isFinite(entry.state.createdAt) && entry.state.createdAt > 0
|
|
336
364
|
? entry.state.createdAt
|
|
337
365
|
: dateStartFromKey(entry.dateKey);
|
|
@@ -343,6 +371,43 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
|
|
|
343
371
|
}
|
|
344
372
|
return results;
|
|
345
373
|
}
|
|
374
|
+
export async function scanAllSessions(statePath, memoryState) {
|
|
375
|
+
const rootPath = chunkRootPathFromStateFile(statePath);
|
|
376
|
+
const deletedSessionIDs = new Set(Object.keys(memoryState?.deletedSessionDateMap || {}));
|
|
377
|
+
const results = [];
|
|
378
|
+
const seenSessionIDs = new Set();
|
|
379
|
+
if (memoryState) {
|
|
380
|
+
for (const [sessionID, sessionState] of Object.entries(memoryState.sessions)) {
|
|
381
|
+
if (deletedSessionIDs.has(sessionID))
|
|
382
|
+
continue;
|
|
383
|
+
const dateKey = memoryState.sessionDateMap[sessionID] ||
|
|
384
|
+
dateKeyFromTimestamp(sessionState.createdAt);
|
|
385
|
+
results.push({ sessionID, dateKey, state: sessionState });
|
|
386
|
+
seenSessionIDs.add(sessionID);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const dateKeys = await discoverChunks(rootPath);
|
|
390
|
+
if (dateKeys.length === 0)
|
|
391
|
+
return results;
|
|
392
|
+
const RANGE_SCAN_CONCURRENCY = 5;
|
|
393
|
+
const chunkEntries = await mapConcurrent(dateKeys, RANGE_SCAN_CONCURRENCY, async (dateKey) => {
|
|
394
|
+
const sessions = await readDayChunk(rootPath, dateKey);
|
|
395
|
+
return Object.entries(sessions).map(([sessionID, state]) => ({
|
|
396
|
+
sessionID,
|
|
397
|
+
dateKey,
|
|
398
|
+
state,
|
|
399
|
+
}));
|
|
400
|
+
});
|
|
401
|
+
for (const entry of chunkEntries.flat()) {
|
|
402
|
+
if (seenSessionIDs.has(entry.sessionID))
|
|
403
|
+
continue;
|
|
404
|
+
if (deletedSessionIDs.has(entry.sessionID))
|
|
405
|
+
continue;
|
|
406
|
+
results.push(entry);
|
|
407
|
+
seenSessionIDs.add(entry.sessionID);
|
|
408
|
+
}
|
|
409
|
+
return results;
|
|
410
|
+
}
|
|
346
411
|
/** Best-effort: remove a session entry from its day chunk (if present). */
|
|
347
412
|
export async function deleteSessionFromDayChunk(statePath, sessionID, dateKey) {
|
|
348
413
|
const rootPath = chunkRootPathFromStateFile(statePath);
|
package/dist/storage_chunks.js
CHANGED
|
@@ -45,14 +45,17 @@ class ChunkCache {
|
|
|
45
45
|
constructor(maxSize = 64) {
|
|
46
46
|
this.maxSize = maxSize;
|
|
47
47
|
}
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
key(rootPath, dateKey) {
|
|
49
|
+
return `${path.resolve(rootPath)}::${dateKey}`;
|
|
50
|
+
}
|
|
51
|
+
get(rootPath, dateKey) {
|
|
52
|
+
const entry = this.cache.get(this.key(rootPath, dateKey));
|
|
50
53
|
if (!entry)
|
|
51
54
|
return undefined;
|
|
52
55
|
entry.accessedAt = Date.now();
|
|
53
56
|
return entry.sessions;
|
|
54
57
|
}
|
|
55
|
-
set(dateKey, sessions) {
|
|
58
|
+
set(rootPath, dateKey, sessions) {
|
|
56
59
|
if (this.cache.size >= this.maxSize) {
|
|
57
60
|
// Evict least recently accessed
|
|
58
61
|
let oldestKey;
|
|
@@ -66,17 +69,20 @@ class ChunkCache {
|
|
|
66
69
|
if (oldestKey)
|
|
67
70
|
this.cache.delete(oldestKey);
|
|
68
71
|
}
|
|
69
|
-
this.cache.set(dateKey, {
|
|
72
|
+
this.cache.set(this.key(rootPath, dateKey), {
|
|
73
|
+
sessions,
|
|
74
|
+
accessedAt: Date.now(),
|
|
75
|
+
});
|
|
70
76
|
}
|
|
71
|
-
invalidate(dateKey) {
|
|
72
|
-
this.cache.delete(dateKey);
|
|
77
|
+
invalidate(rootPath, dateKey) {
|
|
78
|
+
this.cache.delete(this.key(rootPath, dateKey));
|
|
73
79
|
}
|
|
74
80
|
}
|
|
75
81
|
const chunkCache = new ChunkCache();
|
|
76
82
|
export async function readDayChunk(rootPath, dateKey) {
|
|
77
83
|
if (!isDateKey(dateKey))
|
|
78
84
|
return {};
|
|
79
|
-
const cached = chunkCache.get(dateKey);
|
|
85
|
+
const cached = chunkCache.get(rootPath, dateKey);
|
|
80
86
|
if (cached)
|
|
81
87
|
return cached;
|
|
82
88
|
const filePath = chunkFilePath(rootPath, dateKey);
|
|
@@ -101,7 +107,7 @@ export async function readDayChunk(rootPath, dateKey) {
|
|
|
101
107
|
acc[sessionID] = parsedSession;
|
|
102
108
|
return acc;
|
|
103
109
|
}, {});
|
|
104
|
-
chunkCache.set(dateKey, sessions);
|
|
110
|
+
chunkCache.set(rootPath, dateKey, sessions);
|
|
105
111
|
return sessions;
|
|
106
112
|
}
|
|
107
113
|
/**
|
|
@@ -174,13 +180,18 @@ export async function writeDayChunk(rootPath, dateKey, sessions) {
|
|
|
174
180
|
throw new Error(`unsafe chunk root at ${rootPath}`);
|
|
175
181
|
}
|
|
176
182
|
await mkdirpNoSymlink(rootPath, path.dirname(filePath));
|
|
183
|
+
if (Object.keys(sessions).length === 0) {
|
|
184
|
+
await fs.rm(filePath, { force: true }).catch(() => undefined);
|
|
185
|
+
chunkCache.invalidate(rootPath, dateKey);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
177
188
|
const chunk = {
|
|
178
189
|
version: 1,
|
|
179
190
|
dateKey,
|
|
180
191
|
sessions,
|
|
181
192
|
};
|
|
182
193
|
await safeWriteFile(filePath, `${JSON.stringify(chunk, null, 2)}\n`);
|
|
183
|
-
chunkCache.invalidate(dateKey);
|
|
194
|
+
chunkCache.invalidate(rootPath, dateKey);
|
|
184
195
|
}
|
|
185
196
|
export async function discoverChunks(rootPath) {
|
|
186
197
|
const years = await fs.readdir(rootPath).catch(() => []);
|
package/dist/storage_parse.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { asNumber, isRecord } from './helpers.js';
|
|
2
|
+
import { normalizeTimestampMs } from './storage_dates.js';
|
|
2
3
|
function parseSessionTitleState(value) {
|
|
3
4
|
if (!isRecord(value))
|
|
4
5
|
return undefined;
|
|
@@ -26,6 +27,7 @@ function parseProviderUsage(value) {
|
|
|
26
27
|
cost: asNumber(value.cost, 0),
|
|
27
28
|
apiCost: asNumber(value.apiCost, 0),
|
|
28
29
|
assistantMessages: asNumber(value.assistantMessages, 0),
|
|
30
|
+
cacheBuckets: parseCacheUsageBuckets(value.cacheBuckets),
|
|
29
31
|
};
|
|
30
32
|
}
|
|
31
33
|
function parseCacheUsageBucket(value) {
|
|
@@ -107,7 +109,7 @@ export function parseSessionState(value) {
|
|
|
107
109
|
const title = parseSessionTitleState(value);
|
|
108
110
|
if (!title)
|
|
109
111
|
return undefined;
|
|
110
|
-
const createdAt =
|
|
112
|
+
const createdAt = normalizeTimestampMs(value.createdAt, 0);
|
|
111
113
|
if (!createdAt)
|
|
112
114
|
return undefined;
|
|
113
115
|
return {
|
|
@@ -115,6 +117,7 @@ export function parseSessionState(value) {
|
|
|
115
117
|
createdAt,
|
|
116
118
|
parentID: typeof value.parentID === 'string' ? value.parentID : undefined,
|
|
117
119
|
usage: parseCachedUsage(value.usage),
|
|
120
|
+
dirty: value.dirty === true,
|
|
118
121
|
cursor: parseCursor(value.cursor),
|
|
119
122
|
};
|
|
120
123
|
}
|
package/dist/title.js
CHANGED
|
@@ -3,16 +3,15 @@ function sanitizeTitleFragment(value) {
|
|
|
3
3
|
.replace(/[\x00-\x1F\x7F-\x9F]/g, ' ')
|
|
4
4
|
.trimEnd();
|
|
5
5
|
}
|
|
6
|
-
function
|
|
6
|
+
function isCoreDecoratedDetail(line) {
|
|
7
7
|
if (!line)
|
|
8
8
|
return false;
|
|
9
|
-
if (/^Input\s
|
|
9
|
+
if (/^Input\s+\$?[\d.,]+[kKmM]?(?:\s+Output(?:\s+\$?[\d.,]+[kKmM]?)?)?~?$/.test(line)) {
|
|
10
10
|
return true;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (/^Cache(?:\s+Read)?\s+Coverage\s+\S+/.test(line))
|
|
11
|
+
}
|
|
12
|
+
if (/^Cache\s+(Read|Write)\s+\$?\d[\d.,]*[kKmM]?$/.test(line))
|
|
14
13
|
return true;
|
|
15
|
-
if (/^\$\S+\s+as API cost
|
|
14
|
+
if (/^\$\S+\s+as API cost$/.test(line))
|
|
16
15
|
return true;
|
|
17
16
|
// Single-line compact mode compatibility.
|
|
18
17
|
if (/^I(?:nput)?\s+\$?\d[\d.,]*[kKmM]?\s+O(?:utput)?\s+\$?\d[\d.,]*[kKmM]?$/.test(line))
|
|
@@ -23,12 +22,26 @@ function isStrongDecoratedDetail(line) {
|
|
|
23
22
|
return true;
|
|
24
23
|
return false;
|
|
25
24
|
}
|
|
26
|
-
function
|
|
25
|
+
function isSingleLineDecoratedPrefix(line) {
|
|
27
26
|
if (!line)
|
|
28
27
|
return false;
|
|
29
|
-
if (
|
|
30
|
-
return
|
|
31
|
-
|
|
28
|
+
if (/^Input\s+\$?[\d.,]+[kKmM]?~?$/.test(line))
|
|
29
|
+
return true;
|
|
30
|
+
if (/^Input\s+\$?[\d.,]+[kKmM]?\s+Output(?:\s+\$?[\d.,]+[kKmM]?~?)?$/.test(line)) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
if (/^Cache\s+(Read|Write)\s+\$?\d[\d.,]*[kKmM]?(?:~|$)/.test(line)) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
if (/^Cache(?:\s+Read)?\s+Coverage\s+\d[\d.,]*(?:%|~)$/.test(line)) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
if (/^\$\S+\s+as API cost(?:~|$)/.test(line))
|
|
40
|
+
return true;
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
function isSingleLineDetailPrefix(line) {
|
|
44
|
+
return isCoreDecoratedDetail(line) || isSingleLineDecoratedPrefix(line);
|
|
32
45
|
}
|
|
33
46
|
function decoratedSingleLineBase(line) {
|
|
34
47
|
const parts = sanitizeTitleFragment(line)
|
|
@@ -36,18 +49,28 @@ function decoratedSingleLineBase(line) {
|
|
|
36
49
|
.map((part) => part.trim());
|
|
37
50
|
if (parts.length < 2)
|
|
38
51
|
return undefined;
|
|
52
|
+
if (isSingleLineDetailPrefix(parts[0] || ''))
|
|
53
|
+
return undefined;
|
|
39
54
|
const details = parts.slice(1);
|
|
40
|
-
if (!details.some((detail) =>
|
|
55
|
+
if (!details.some((detail) => isSingleLineDetailPrefix(detail))) {
|
|
41
56
|
return undefined;
|
|
42
57
|
}
|
|
43
58
|
return parts[0] || 'Session';
|
|
44
59
|
}
|
|
45
60
|
export function normalizeBaseTitle(title) {
|
|
46
|
-
const
|
|
61
|
+
const safeTitle = canonicalizeTitle(title) || 'Session';
|
|
62
|
+
const firstLine = stripAnsi(safeTitle).split(/\r?\n/, 1)[0] || 'Session';
|
|
47
63
|
const decoratedBase = decoratedSingleLineBase(firstLine);
|
|
48
64
|
if (decoratedBase)
|
|
49
65
|
return decoratedBase;
|
|
50
|
-
|
|
66
|
+
const lines = stripAnsi(safeTitle).split(/\r?\n/);
|
|
67
|
+
if (lines.length > 1) {
|
|
68
|
+
const detail = lines.slice(1).map((line) => sanitizeTitleFragment(line).trim());
|
|
69
|
+
if (detail.some((line) => isCoreDecoratedDetail(line))) {
|
|
70
|
+
return sanitizeTitleFragment(firstLine) || 'Session';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return safeTitle;
|
|
51
74
|
}
|
|
52
75
|
export function stripAnsi(value) {
|
|
53
76
|
// Remove terminal escape sequences. Sidebar titles must be plain text.
|
|
@@ -93,6 +116,5 @@ export function looksDecorated(title) {
|
|
|
93
116
|
const detail = lines
|
|
94
117
|
.slice(1)
|
|
95
118
|
.map((line) => sanitizeTitleFragment(line).trim());
|
|
96
|
-
return
|
|
97
|
-
detail.some((line) => isQuotaLikeProviderDetail(line)));
|
|
119
|
+
return detail.some((line) => isCoreDecoratedDetail(line));
|
|
98
120
|
}
|