@leo000001/opencode-quota-sidebar 1.13.10 → 2.0.1
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 +21 -17
- package/dist/cost.d.ts +2 -0
- package/dist/cost.js +11 -1
- package/dist/format.d.ts +1 -1
- package/dist/format.js +78 -21
- package/dist/index.js +24 -2
- package/dist/quota.js +3 -5
- package/dist/quota_service.js +65 -18
- 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 +36 -1
- package/dist/title.js +37 -13
- package/dist/title_apply.d.ts +2 -0
- package/dist/title_apply.js +43 -13
- package/dist/title_refresh.d.ts +2 -0
- package/dist/title_refresh.js +12 -1
- package/dist/tools.d.ts +5 -0
- package/dist/tools.js +8 -4
- package/dist/types.d.ts +37 -0
- package/dist/usage.d.ts +15 -2
- package/dist/usage.js +166 -7
- package/dist/usage_service.js +158 -55
- package/package.json +1 -1
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;
|
|
@@ -28,6 +29,38 @@ function parseProviderUsage(value) {
|
|
|
28
29
|
assistantMessages: asNumber(value.assistantMessages, 0),
|
|
29
30
|
};
|
|
30
31
|
}
|
|
32
|
+
function parseCacheUsageBucket(value) {
|
|
33
|
+
if (!isRecord(value))
|
|
34
|
+
return undefined;
|
|
35
|
+
return {
|
|
36
|
+
input: asNumber(value.input, 0),
|
|
37
|
+
cacheRead: asNumber(value.cacheRead, 0),
|
|
38
|
+
cacheWrite: asNumber(value.cacheWrite, 0),
|
|
39
|
+
assistantMessages: asNumber(value.assistantMessages, 0),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function parseCacheUsageBuckets(value) {
|
|
43
|
+
if (!isRecord(value))
|
|
44
|
+
return undefined;
|
|
45
|
+
const readOnly = parseCacheUsageBucket(value.readOnly);
|
|
46
|
+
const readWrite = parseCacheUsageBucket(value.readWrite);
|
|
47
|
+
if (!readOnly && !readWrite)
|
|
48
|
+
return undefined;
|
|
49
|
+
return {
|
|
50
|
+
readOnly: readOnly || {
|
|
51
|
+
input: 0,
|
|
52
|
+
cacheRead: 0,
|
|
53
|
+
cacheWrite: 0,
|
|
54
|
+
assistantMessages: 0,
|
|
55
|
+
},
|
|
56
|
+
readWrite: readWrite || {
|
|
57
|
+
input: 0,
|
|
58
|
+
cacheRead: 0,
|
|
59
|
+
cacheWrite: 0,
|
|
60
|
+
assistantMessages: 0,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
31
64
|
function parseCachedUsage(value) {
|
|
32
65
|
if (!isRecord(value))
|
|
33
66
|
return undefined;
|
|
@@ -50,6 +83,7 @@ function parseCachedUsage(value) {
|
|
|
50
83
|
cost: asNumber(value.cost, 0),
|
|
51
84
|
apiCost: asNumber(value.apiCost, 0),
|
|
52
85
|
assistantMessages: asNumber(value.assistantMessages, 0),
|
|
86
|
+
cacheBuckets: parseCacheUsageBuckets(value.cacheBuckets),
|
|
53
87
|
providers,
|
|
54
88
|
};
|
|
55
89
|
}
|
|
@@ -74,7 +108,7 @@ export function parseSessionState(value) {
|
|
|
74
108
|
const title = parseSessionTitleState(value);
|
|
75
109
|
if (!title)
|
|
76
110
|
return undefined;
|
|
77
|
-
const createdAt =
|
|
111
|
+
const createdAt = normalizeTimestampMs(value.createdAt, 0);
|
|
78
112
|
if (!createdAt)
|
|
79
113
|
return undefined;
|
|
80
114
|
return {
|
|
@@ -82,6 +116,7 @@ export function parseSessionState(value) {
|
|
|
82
116
|
createdAt,
|
|
83
117
|
parentID: typeof value.parentID === 'string' ? value.parentID : undefined,
|
|
84
118
|
usage: parseCachedUsage(value.usage),
|
|
119
|
+
dirty: value.dirty === true,
|
|
85
120
|
cursor: parseCursor(value.cursor),
|
|
86
121
|
};
|
|
87
122
|
}
|
package/dist/title.js
CHANGED
|
@@ -3,14 +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
|
-
|
|
11
|
+
}
|
|
12
|
+
if (/^Cache\s+(Read|Write)\s+\$?\d[\d.,]*[kKmM]?$/.test(line))
|
|
12
13
|
return true;
|
|
13
|
-
if (/^\$\S+\s+as API cost
|
|
14
|
+
if (/^\$\S+\s+as API cost$/.test(line))
|
|
14
15
|
return true;
|
|
15
16
|
// Single-line compact mode compatibility.
|
|
16
17
|
if (/^I(?:nput)?\s+\$?\d[\d.,]*[kKmM]?\s+O(?:utput)?\s+\$?\d[\d.,]*[kKmM]?$/.test(line))
|
|
@@ -21,12 +22,26 @@ function isStrongDecoratedDetail(line) {
|
|
|
21
22
|
return true;
|
|
22
23
|
return false;
|
|
23
24
|
}
|
|
24
|
-
function
|
|
25
|
+
function isSingleLineDecoratedPrefix(line) {
|
|
25
26
|
if (!line)
|
|
26
27
|
return false;
|
|
27
|
-
if (
|
|
28
|
-
return
|
|
29
|
-
|
|
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);
|
|
30
45
|
}
|
|
31
46
|
function decoratedSingleLineBase(line) {
|
|
32
47
|
const parts = sanitizeTitleFragment(line)
|
|
@@ -34,18 +49,28 @@ function decoratedSingleLineBase(line) {
|
|
|
34
49
|
.map((part) => part.trim());
|
|
35
50
|
if (parts.length < 2)
|
|
36
51
|
return undefined;
|
|
52
|
+
if (isSingleLineDetailPrefix(parts[0] || ''))
|
|
53
|
+
return undefined;
|
|
37
54
|
const details = parts.slice(1);
|
|
38
|
-
if (!details.some((detail) =>
|
|
55
|
+
if (!details.some((detail) => isSingleLineDetailPrefix(detail))) {
|
|
39
56
|
return undefined;
|
|
40
57
|
}
|
|
41
58
|
return parts[0] || 'Session';
|
|
42
59
|
}
|
|
43
60
|
export function normalizeBaseTitle(title) {
|
|
44
|
-
const
|
|
61
|
+
const safeTitle = canonicalizeTitle(title) || 'Session';
|
|
62
|
+
const firstLine = stripAnsi(safeTitle).split(/\r?\n/, 1)[0] || 'Session';
|
|
45
63
|
const decoratedBase = decoratedSingleLineBase(firstLine);
|
|
46
64
|
if (decoratedBase)
|
|
47
65
|
return decoratedBase;
|
|
48
|
-
|
|
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;
|
|
49
74
|
}
|
|
50
75
|
export function stripAnsi(value) {
|
|
51
76
|
// Remove terminal escape sequences. Sidebar titles must be plain text.
|
|
@@ -91,6 +116,5 @@ export function looksDecorated(title) {
|
|
|
91
116
|
const detail = lines
|
|
92
117
|
.slice(1)
|
|
93
118
|
.map((line) => sanitizeTitleFragment(line).trim());
|
|
94
|
-
return
|
|
95
|
-
detail.some((line) => isQuotaLikeProviderDetail(line)));
|
|
119
|
+
return detail.some((line) => isCoreDecoratedDetail(line));
|
|
96
120
|
}
|
package/dist/title_apply.d.ts
CHANGED
|
@@ -26,5 +26,7 @@ export declare function createTitleApplicator(deps: {
|
|
|
26
26
|
}) => Promise<void>;
|
|
27
27
|
restoreSessionTitle: (sessionID: string) => Promise<void>;
|
|
28
28
|
restoreAllVisibleTitles: () => Promise<void>;
|
|
29
|
+
refreshAllTouchedTitles: () => Promise<void>;
|
|
30
|
+
refreshAllVisibleTitles: () => Promise<void>;
|
|
29
31
|
forgetSession: (sessionID: string) => void;
|
|
30
32
|
};
|
package/dist/title_apply.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { canonicalizeTitle, canonicalizeTitleForCompare, looksDecorated,
|
|
1
|
+
import { canonicalizeTitle, canonicalizeTitleForCompare, looksDecorated, } from './title.js';
|
|
2
2
|
import { swallow, debug, mapConcurrent } from './helpers.js';
|
|
3
3
|
export function createTitleApplicator(deps) {
|
|
4
4
|
const pendingAppliedTitle = new Map();
|
|
@@ -44,7 +44,7 @@ export function createTitleApplicator(deps) {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
else {
|
|
47
|
-
const nextBase =
|
|
47
|
+
const nextBase = canonicalizeTitle(currentTitle) || 'Session';
|
|
48
48
|
if (sessionState.baseTitle !== nextBase) {
|
|
49
49
|
sessionState.baseTitle = nextBase;
|
|
50
50
|
stateMutated = true;
|
|
@@ -61,6 +61,8 @@ export function createTitleApplicator(deps) {
|
|
|
61
61
|
? await deps.getQuotaSnapshots(quotaProviders)
|
|
62
62
|
: [];
|
|
63
63
|
const nextTitle = deps.renderSidebarTitle(sessionState.baseTitle, usage, quotas, deps.config);
|
|
64
|
+
if (!deps.config.sidebar.enabled || !deps.state.titleEnabled)
|
|
65
|
+
return;
|
|
64
66
|
if (canonicalizeTitleForCompare(nextTitle) ===
|
|
65
67
|
canonicalizeTitleForCompare(session.data.title)) {
|
|
66
68
|
if (looksDecorated(session.data.title)) {
|
|
@@ -129,11 +131,14 @@ export function createTitleApplicator(deps) {
|
|
|
129
131
|
canonicalizeTitleForCompare(args.sessionState.lastAppliedTitle || '')) {
|
|
130
132
|
return;
|
|
131
133
|
}
|
|
132
|
-
if (looksDecorated(args.incomingTitle)) {
|
|
133
|
-
|
|
134
|
-
|
|
134
|
+
if (looksDecorated(args.incomingTitle) && args.sessionState.lastAppliedTitle) {
|
|
135
|
+
if (canonicalizeTitleForCompare(args.incomingTitle) ===
|
|
136
|
+
canonicalizeTitleForCompare(args.sessionState.lastAppliedTitle)) {
|
|
137
|
+
debug(`ignoring late decorated echo for session ${args.sessionID}`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
135
140
|
}
|
|
136
|
-
args.sessionState.baseTitle =
|
|
141
|
+
args.sessionState.baseTitle = canonicalizeTitle(args.incomingTitle) || 'Session';
|
|
137
142
|
args.sessionState.lastAppliedTitle = undefined;
|
|
138
143
|
deps.markDirty(deps.state.sessionDateMap[args.sessionID]);
|
|
139
144
|
deps.scheduleSave();
|
|
@@ -150,10 +155,16 @@ export function createTitleApplicator(deps) {
|
|
|
150
155
|
if (!session)
|
|
151
156
|
return;
|
|
152
157
|
const sessionState = deps.ensureSessionState(sessionID, session.data.title, session.data.time.created, session.data.parentID ?? null);
|
|
153
|
-
const baseTitle =
|
|
154
|
-
if (session.data.title === baseTitle)
|
|
158
|
+
const baseTitle = canonicalizeTitle(sessionState.baseTitle) || 'Session';
|
|
159
|
+
if (session.data.title === baseTitle) {
|
|
160
|
+
if (sessionState.lastAppliedTitle !== undefined) {
|
|
161
|
+
sessionState.lastAppliedTitle = undefined;
|
|
162
|
+
deps.markDirty(deps.state.sessionDateMap[sessionID]);
|
|
163
|
+
deps.scheduleSave();
|
|
164
|
+
}
|
|
155
165
|
return;
|
|
156
|
-
|
|
166
|
+
}
|
|
167
|
+
const updated = await deps.client.session
|
|
157
168
|
.update({
|
|
158
169
|
path: { id: sessionID },
|
|
159
170
|
query: { directory: deps.directory },
|
|
@@ -161,22 +172,39 @@ export function createTitleApplicator(deps) {
|
|
|
161
172
|
throwOnError: true,
|
|
162
173
|
})
|
|
163
174
|
.catch(swallow('restoreSessionTitle:update'));
|
|
175
|
+
if (!updated)
|
|
176
|
+
return;
|
|
164
177
|
sessionState.lastAppliedTitle = undefined;
|
|
165
178
|
deps.markDirty(deps.state.sessionDateMap[sessionID]);
|
|
166
179
|
deps.scheduleSave();
|
|
167
180
|
};
|
|
168
181
|
const restoreAllVisibleTitles = async () => {
|
|
182
|
+
const touched = Object.entries(deps.state.sessions)
|
|
183
|
+
.filter(([, sessionState]) => Boolean(sessionState.lastAppliedTitle))
|
|
184
|
+
.map(([sessionID]) => sessionID);
|
|
185
|
+
await mapConcurrent(touched, deps.restoreConcurrency, async (sessionID) => {
|
|
186
|
+
await restoreSessionTitle(sessionID);
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
const refreshAllTouchedTitles = async () => {
|
|
190
|
+
const touched = Object.entries(deps.state.sessions)
|
|
191
|
+
.filter(([, sessionState]) => Boolean(sessionState.lastAppliedTitle))
|
|
192
|
+
.map(([sessionID]) => sessionID);
|
|
193
|
+
await mapConcurrent(touched, deps.restoreConcurrency, async (sessionID) => {
|
|
194
|
+
await applyTitle(sessionID);
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
const refreshAllVisibleTitles = async () => {
|
|
169
198
|
const list = await deps.client.session
|
|
170
199
|
.list({
|
|
171
200
|
query: { directory: deps.directory },
|
|
172
201
|
throwOnError: true,
|
|
173
202
|
})
|
|
174
|
-
.catch(swallow('
|
|
203
|
+
.catch(swallow('refreshAllVisibleTitles:list'));
|
|
175
204
|
if (!list?.data)
|
|
176
205
|
return;
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
await restoreSessionTitle(s.id);
|
|
206
|
+
await mapConcurrent(list.data, deps.restoreConcurrency, async (session) => {
|
|
207
|
+
await applyTitle(session.id);
|
|
180
208
|
});
|
|
181
209
|
};
|
|
182
210
|
return {
|
|
@@ -184,6 +212,8 @@ export function createTitleApplicator(deps) {
|
|
|
184
212
|
handleSessionUpdatedTitle,
|
|
185
213
|
restoreSessionTitle,
|
|
186
214
|
restoreAllVisibleTitles,
|
|
215
|
+
refreshAllTouchedTitles,
|
|
216
|
+
refreshAllVisibleTitles,
|
|
187
217
|
forgetSession,
|
|
188
218
|
};
|
|
189
219
|
}
|
package/dist/title_refresh.d.ts
CHANGED
|
@@ -5,5 +5,7 @@ export declare function createTitleRefreshScheduler(options: {
|
|
|
5
5
|
schedule: (sessionID: string, delay?: number) => void;
|
|
6
6
|
apply: (sessionID: string) => Promise<void>;
|
|
7
7
|
cancel: (sessionID: string) => void;
|
|
8
|
+
cancelAll: () => void;
|
|
9
|
+
waitForIdle: () => Promise<void>;
|
|
8
10
|
dispose: () => void;
|
|
9
11
|
};
|
package/dist/title_refresh.js
CHANGED
|
@@ -31,16 +31,27 @@ export function createTitleRefreshScheduler(options) {
|
|
|
31
31
|
clearTimeout(timer);
|
|
32
32
|
refreshTimer.delete(sessionID);
|
|
33
33
|
};
|
|
34
|
-
const
|
|
34
|
+
const cancelAll = () => {
|
|
35
35
|
for (const timer of refreshTimer.values())
|
|
36
36
|
clearTimeout(timer);
|
|
37
37
|
refreshTimer.clear();
|
|
38
|
+
};
|
|
39
|
+
const waitForIdle = async () => {
|
|
40
|
+
const inflight = Array.from(applyLocks.values());
|
|
41
|
+
if (inflight.length === 0)
|
|
42
|
+
return;
|
|
43
|
+
await Promise.allSettled(inflight);
|
|
44
|
+
};
|
|
45
|
+
const dispose = () => {
|
|
46
|
+
cancelAll();
|
|
38
47
|
applyLocks.clear();
|
|
39
48
|
};
|
|
40
49
|
return {
|
|
41
50
|
schedule,
|
|
42
51
|
apply: applyLocked,
|
|
43
52
|
cancel,
|
|
53
|
+
cancelAll,
|
|
54
|
+
waitForIdle,
|
|
44
55
|
dispose,
|
|
45
56
|
};
|
|
46
57
|
}
|
package/dist/tools.d.ts
CHANGED
|
@@ -4,8 +4,13 @@ export declare function createQuotaSidebarTools(deps: {
|
|
|
4
4
|
getTitleEnabled: () => boolean;
|
|
5
5
|
setTitleEnabled: (enabled: boolean) => void;
|
|
6
6
|
scheduleSave: () => void;
|
|
7
|
+
flushSave: () => Promise<void>;
|
|
7
8
|
refreshSessionTitle: (sessionID: string, delay?: number) => void;
|
|
9
|
+
cancelAllTitleRefreshes: () => void;
|
|
10
|
+
waitForTitleRefreshIdle: () => Promise<void>;
|
|
8
11
|
restoreAllVisibleTitles: () => Promise<void>;
|
|
12
|
+
refreshAllTouchedTitles: () => Promise<void>;
|
|
13
|
+
refreshAllVisibleTitles: () => Promise<void>;
|
|
9
14
|
showToast: (period: 'session' | 'day' | 'week' | 'month' | 'toggle', message: string) => Promise<void>;
|
|
10
15
|
summarizeForTool: (period: 'session' | 'day' | 'week' | 'month', sessionID: string, includeChildren: boolean) => Promise<UsageSummary>;
|
|
11
16
|
getQuotaSnapshots: (providerIDs: string[], options?: {
|
package/dist/tools.js
CHANGED
|
@@ -18,7 +18,6 @@ export function createQuotaSidebarTools(deps) {
|
|
|
18
18
|
? (args.includeChildren ?? deps.config.sidebar.includeChildren)
|
|
19
19
|
: false;
|
|
20
20
|
const usage = await deps.summarizeForTool(period, context.sessionID, includeChildren);
|
|
21
|
-
deps.scheduleSave();
|
|
22
21
|
// For quota_summary, always show all subscription quota balances,
|
|
23
22
|
// regardless of which providers were used in the session.
|
|
24
23
|
const quotas = await deps.getQuotaSnapshots([], { allowDefault: true });
|
|
@@ -47,16 +46,21 @@ export function createQuotaSidebarTools(deps) {
|
|
|
47
46
|
const next = args.enabled !== undefined ? args.enabled : !current;
|
|
48
47
|
deps.setTitleEnabled(next);
|
|
49
48
|
deps.scheduleSave();
|
|
49
|
+
await deps.flushSave();
|
|
50
50
|
if (next) {
|
|
51
|
-
// Turning on —
|
|
51
|
+
// Turning on — refresh visible sessions, plus touched sessions as backup.
|
|
52
|
+
await deps.refreshAllVisibleTitles();
|
|
53
|
+
await deps.refreshAllTouchedTitles();
|
|
52
54
|
deps.refreshSessionTitle(context.sessionID, 0);
|
|
53
55
|
await deps.showToast('toggle', 'Sidebar usage display: ON');
|
|
54
|
-
return 'Sidebar usage display is now ON.
|
|
56
|
+
return 'Sidebar usage display is now ON. Visible session titles are refreshing to show token usage and quota.';
|
|
55
57
|
}
|
|
56
58
|
// Turning off — restore all touched sessions to base titles
|
|
59
|
+
deps.cancelAllTitleRefreshes();
|
|
60
|
+
await deps.waitForTitleRefreshIdle();
|
|
57
61
|
await deps.restoreAllVisibleTitles();
|
|
58
62
|
await deps.showToast('toggle', 'Sidebar usage display: OFF');
|
|
59
|
-
return 'Sidebar usage display is now OFF.
|
|
63
|
+
return 'Sidebar usage display is now OFF. Restore was attempted for touched session titles.';
|
|
60
64
|
},
|
|
61
65
|
}),
|
|
62
66
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -36,6 +36,31 @@ export type SessionTitleState = {
|
|
|
36
36
|
baseTitle: string;
|
|
37
37
|
lastAppliedTitle?: string;
|
|
38
38
|
};
|
|
39
|
+
export type CacheCoverageMode = 'none' | 'read-only' | 'read-write';
|
|
40
|
+
export type CacheUsageBucket = {
|
|
41
|
+
input: number;
|
|
42
|
+
cacheRead: number;
|
|
43
|
+
cacheWrite: number;
|
|
44
|
+
assistantMessages: number;
|
|
45
|
+
};
|
|
46
|
+
export type CacheUsageBuckets = {
|
|
47
|
+
readOnly: CacheUsageBucket;
|
|
48
|
+
readWrite: CacheUsageBucket;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Derived cache coverage metrics.
|
|
52
|
+
*
|
|
53
|
+
* - `cacheCoverage`: fraction of prompt surface covered by read-write cache
|
|
54
|
+
* (`(cacheRead + cacheWrite) / (input + cacheRead + cacheWrite)`).
|
|
55
|
+
* Only defined when the read-write bucket has traffic.
|
|
56
|
+
* - `cacheReadCoverage`: fraction of prompt surface served from read-only cache
|
|
57
|
+
* (`cacheRead / (input + cacheRead)`).
|
|
58
|
+
* Only defined when the read-only bucket has traffic.
|
|
59
|
+
*/
|
|
60
|
+
export type CacheCoverageMetrics = {
|
|
61
|
+
cacheCoverage: number | undefined;
|
|
62
|
+
cacheReadCoverage: number | undefined;
|
|
63
|
+
};
|
|
39
64
|
export type CachedProviderUsage = {
|
|
40
65
|
input: number;
|
|
41
66
|
output: number;
|
|
@@ -61,6 +86,14 @@ export type CachedSessionUsage = {
|
|
|
61
86
|
/** Equivalent API billing cost (USD) computed from model pricing. */
|
|
62
87
|
apiCost: number;
|
|
63
88
|
assistantMessages: number;
|
|
89
|
+
/**
|
|
90
|
+
* Cache coverage buckets grouped by model cache behavior.
|
|
91
|
+
*
|
|
92
|
+
* `undefined` when no cache-capable models were used or data predates
|
|
93
|
+
* billingVersion 3. The fallback in `resolvedCacheUsageBuckets()` derives
|
|
94
|
+
* approximate buckets from top-level `cacheRead`/`cacheWrite` when missing.
|
|
95
|
+
*/
|
|
96
|
+
cacheBuckets?: CacheUsageBuckets;
|
|
64
97
|
providers: Record<string, CachedProviderUsage>;
|
|
65
98
|
};
|
|
66
99
|
/** Tracks incremental aggregation cursor for a session (P1). */
|
|
@@ -77,6 +110,8 @@ export type SessionState = SessionTitleState & {
|
|
|
77
110
|
/** Parent session ID for subagent child sessions. */
|
|
78
111
|
parentID?: string;
|
|
79
112
|
usage?: CachedSessionUsage;
|
|
113
|
+
/** Persisted dirtiness flag so descendant aggregation survives restart. */
|
|
114
|
+
dirty?: boolean;
|
|
80
115
|
/** Incremental aggregation cursor (P1). */
|
|
81
116
|
cursor?: IncrementalCursor;
|
|
82
117
|
};
|
|
@@ -91,6 +126,8 @@ export type QuotaSidebarState = {
|
|
|
91
126
|
titleEnabled: boolean;
|
|
92
127
|
sessionDateMap: Record<string, string>;
|
|
93
128
|
sessions: Record<string, SessionState>;
|
|
129
|
+
/** Tombstones for sessions deleted from memory but not yet purged from day chunks. */
|
|
130
|
+
deletedSessionDateMap: Record<string, string>;
|
|
94
131
|
quotaCache: Record<string, QuotaSnapshot>;
|
|
95
132
|
};
|
|
96
133
|
export type QuotaSidebarConfig = {
|