@leo000001/opencode-quota-sidebar 2.0.0 → 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 +14 -12
- package/dist/cost.js +2 -1
- package/dist/format.js +37 -22
- 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 +3 -1
- package/dist/title.js +37 -15
- 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 +25 -1
- package/dist/usage.d.ts +12 -6
- package/dist/usage.js +63 -13
- package/dist/usage_service.js +121 -48
- package/package.json +1 -1
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;
|
|
@@ -107,7 +108,7 @@ export function parseSessionState(value) {
|
|
|
107
108
|
const title = parseSessionTitleState(value);
|
|
108
109
|
if (!title)
|
|
109
110
|
return undefined;
|
|
110
|
-
const createdAt =
|
|
111
|
+
const createdAt = normalizeTimestampMs(value.createdAt, 0);
|
|
111
112
|
if (!createdAt)
|
|
112
113
|
return undefined;
|
|
113
114
|
return {
|
|
@@ -115,6 +116,7 @@ export function parseSessionState(value) {
|
|
|
115
116
|
createdAt,
|
|
116
117
|
parentID: typeof value.parentID === 'string' ? value.parentID : undefined,
|
|
117
118
|
usage: parseCachedUsage(value.usage),
|
|
119
|
+
dirty: value.dirty === true,
|
|
118
120
|
cursor: parseCursor(value.cursor),
|
|
119
121
|
};
|
|
120
122
|
}
|
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
|
}
|
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
|
@@ -47,6 +47,20 @@ export type CacheUsageBuckets = {
|
|
|
47
47
|
readOnly: CacheUsageBucket;
|
|
48
48
|
readWrite: CacheUsageBucket;
|
|
49
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
|
+
};
|
|
50
64
|
export type CachedProviderUsage = {
|
|
51
65
|
input: number;
|
|
52
66
|
output: number;
|
|
@@ -72,7 +86,13 @@ export type CachedSessionUsage = {
|
|
|
72
86
|
/** Equivalent API billing cost (USD) computed from model pricing. */
|
|
73
87
|
apiCost: number;
|
|
74
88
|
assistantMessages: number;
|
|
75
|
-
/**
|
|
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
|
+
*/
|
|
76
96
|
cacheBuckets?: CacheUsageBuckets;
|
|
77
97
|
providers: Record<string, CachedProviderUsage>;
|
|
78
98
|
};
|
|
@@ -90,6 +110,8 @@ export type SessionState = SessionTitleState & {
|
|
|
90
110
|
/** Parent session ID for subagent child sessions. */
|
|
91
111
|
parentID?: string;
|
|
92
112
|
usage?: CachedSessionUsage;
|
|
113
|
+
/** Persisted dirtiness flag so descendant aggregation survives restart. */
|
|
114
|
+
dirty?: boolean;
|
|
93
115
|
/** Incremental aggregation cursor (P1). */
|
|
94
116
|
cursor?: IncrementalCursor;
|
|
95
117
|
};
|
|
@@ -104,6 +126,8 @@ export type QuotaSidebarState = {
|
|
|
104
126
|
titleEnabled: boolean;
|
|
105
127
|
sessionDateMap: Record<string, string>;
|
|
106
128
|
sessions: Record<string, SessionState>;
|
|
129
|
+
/** Tombstones for sessions deleted from memory but not yet purged from day chunks. */
|
|
130
|
+
deletedSessionDateMap: Record<string, string>;
|
|
107
131
|
quotaCache: Record<string, QuotaSnapshot>;
|
|
108
132
|
};
|
|
109
133
|
export type QuotaSidebarConfig = {
|
package/dist/usage.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { AssistantMessage, Message } from '@opencode-ai/sdk';
|
|
2
|
-
import type { CacheCoverageMode, CacheUsageBuckets, CachedSessionUsage, IncrementalCursor } from './types.js';
|
|
3
|
-
|
|
2
|
+
import type { CacheCoverageMetrics, CacheCoverageMode, CacheUsageBuckets, CachedSessionUsage, IncrementalCursor } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Billing cache version — bump this whenever the persisted `CachedSessionUsage`
|
|
5
|
+
* shape changes in a way that requires recomputation (e.g. new aggregate
|
|
6
|
+
* fields). This is distinct from the plugin *state* version managed by the
|
|
7
|
+
* persistence layer; billing version only governs usage-cache staleness.
|
|
8
|
+
*/
|
|
9
|
+
export declare const USAGE_BILLING_CACHE_VERSION = 4;
|
|
4
10
|
export type ProviderUsage = {
|
|
5
11
|
providerID: string;
|
|
6
12
|
input: number;
|
|
@@ -35,14 +41,14 @@ export type UsageOptions = {
|
|
|
35
41
|
/** Cache-behavior classifier for the message model/provider. */
|
|
36
42
|
classifyCacheMode?: (message: AssistantMessage) => CacheCoverageMode;
|
|
37
43
|
};
|
|
38
|
-
export declare function getCacheCoverageMetrics(usage: Pick<UsageSummary, 'input' | 'cacheRead' | 'cacheWrite' | 'assistantMessages' | 'cacheBuckets'>):
|
|
39
|
-
cacheCoverage: number | undefined;
|
|
40
|
-
cacheReadCoverage: number | undefined;
|
|
41
|
-
};
|
|
44
|
+
export declare function getCacheCoverageMetrics(usage: Pick<UsageSummary, 'input' | 'cacheRead' | 'cacheWrite' | 'assistantMessages' | 'cacheBuckets'>): CacheCoverageMetrics;
|
|
42
45
|
export declare function emptyUsageSummary(): UsageSummary;
|
|
43
46
|
export declare function summarizeMessages(entries: Array<{
|
|
44
47
|
info: Message;
|
|
45
48
|
}>, startAt?: number, sessionCount?: number, options?: UsageOptions): UsageSummary;
|
|
49
|
+
export declare function summarizeMessagesInCompletedRange(entries: Array<{
|
|
50
|
+
info: Message;
|
|
51
|
+
}>, startAt: number, endAt: number, sessionCount?: number, options?: UsageOptions): UsageSummary;
|
|
46
52
|
/**
|
|
47
53
|
* P1: Incremental usage aggregation.
|
|
48
54
|
* Only processes messages newer than the cursor. Returns updated cursor.
|
package/dist/usage.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Billing cache version — bump this whenever the persisted `CachedSessionUsage`
|
|
3
|
+
* shape changes in a way that requires recomputation (e.g. new aggregate
|
|
4
|
+
* fields). This is distinct from the plugin *state* version managed by the
|
|
5
|
+
* persistence layer; billing version only governs usage-cache staleness.
|
|
6
|
+
*/
|
|
7
|
+
export const USAGE_BILLING_CACHE_VERSION = 4;
|
|
2
8
|
function emptyCacheUsageBucket() {
|
|
3
9
|
return {
|
|
4
10
|
input: 0,
|
|
@@ -25,8 +31,8 @@ function cloneCacheUsageBuckets(buckets) {
|
|
|
25
31
|
if (!buckets)
|
|
26
32
|
return undefined;
|
|
27
33
|
return {
|
|
28
|
-
readOnly: cloneCacheUsageBucket(buckets
|
|
29
|
-
readWrite: cloneCacheUsageBucket(buckets
|
|
34
|
+
readOnly: cloneCacheUsageBucket(buckets.readOnly),
|
|
35
|
+
readWrite: cloneCacheUsageBucket(buckets.readWrite),
|
|
30
36
|
};
|
|
31
37
|
}
|
|
32
38
|
function mergeCacheUsageBucket(target, source) {
|
|
@@ -44,6 +50,14 @@ function addMessageCacheUsage(target, message) {
|
|
|
44
50
|
target.cacheWrite += message.tokens.cache.write;
|
|
45
51
|
target.assistantMessages += 1;
|
|
46
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Best-effort fallback for legacy cached data that lacks per-message cache
|
|
55
|
+
* buckets. When `cacheWrite > 0` we assume all tokens came from a read-write
|
|
56
|
+
* model (Anthropic-like); when only `cacheRead > 0` we assume read-only
|
|
57
|
+
* (OpenAI-like). Mixed-provider sessions that were cached before v3 will be
|
|
58
|
+
* attributed to a single bucket — this is a known limitation; new sessions
|
|
59
|
+
* classify per-message and are not affected.
|
|
60
|
+
*/
|
|
47
61
|
function fallbackCacheUsageBuckets(usage) {
|
|
48
62
|
if (usage.cacheWrite > 0) {
|
|
49
63
|
return {
|
|
@@ -70,8 +84,25 @@ function fallbackCacheUsageBuckets(usage) {
|
|
|
70
84
|
return undefined;
|
|
71
85
|
}
|
|
72
86
|
function resolvedCacheUsageBuckets(usage) {
|
|
73
|
-
|
|
74
|
-
|
|
87
|
+
const explicit = cloneCacheUsageBuckets(usage.cacheBuckets);
|
|
88
|
+
if (!explicit) {
|
|
89
|
+
return cloneCacheUsageBuckets(fallbackCacheUsageBuckets(usage)) || emptyCacheUsageBuckets();
|
|
90
|
+
}
|
|
91
|
+
const accountedInput = explicit.readOnly.input + explicit.readWrite.input;
|
|
92
|
+
const accountedCacheRead = explicit.readOnly.cacheRead + explicit.readWrite.cacheRead;
|
|
93
|
+
const accountedCacheWrite = explicit.readOnly.cacheWrite + explicit.readWrite.cacheWrite;
|
|
94
|
+
const accountedAssistantMessages = explicit.readOnly.assistantMessages + explicit.readWrite.assistantMessages;
|
|
95
|
+
const residual = fallbackCacheUsageBuckets({
|
|
96
|
+
input: Math.max(0, usage.input - accountedInput),
|
|
97
|
+
cacheRead: Math.max(0, usage.cacheRead - accountedCacheRead),
|
|
98
|
+
cacheWrite: Math.max(0, usage.cacheWrite - accountedCacheWrite),
|
|
99
|
+
assistantMessages: Math.max(0, usage.assistantMessages - accountedAssistantMessages),
|
|
100
|
+
});
|
|
101
|
+
if (residual) {
|
|
102
|
+
mergeCacheUsageBucket(explicit.readOnly, residual.readOnly);
|
|
103
|
+
mergeCacheUsageBucket(explicit.readWrite, residual.readWrite);
|
|
104
|
+
}
|
|
105
|
+
return explicit;
|
|
75
106
|
}
|
|
76
107
|
export function getCacheCoverageMetrics(usage) {
|
|
77
108
|
const buckets = resolvedCacheUsageBuckets(usage);
|
|
@@ -101,7 +132,6 @@ export function emptyUsageSummary() {
|
|
|
101
132
|
apiCost: 0,
|
|
102
133
|
assistantMessages: 0,
|
|
103
134
|
sessionCount: 0,
|
|
104
|
-
cacheBuckets: emptyCacheUsageBuckets(),
|
|
105
135
|
providers: {},
|
|
106
136
|
};
|
|
107
137
|
}
|
|
@@ -170,17 +200,37 @@ function addMessageUsage(target, message, options) {
|
|
|
170
200
|
addMessageCacheUsage(buckets.readWrite, message);
|
|
171
201
|
}
|
|
172
202
|
}
|
|
203
|
+
function completedTimeOf(message) {
|
|
204
|
+
const completed = message.time.completed;
|
|
205
|
+
if (typeof completed !== 'number')
|
|
206
|
+
return undefined;
|
|
207
|
+
if (!Number.isFinite(completed))
|
|
208
|
+
return undefined;
|
|
209
|
+
return completed;
|
|
210
|
+
}
|
|
211
|
+
function isCompletedAssistantInRange(message, startAt = 0, endAt = Number.POSITIVE_INFINITY) {
|
|
212
|
+
if (!isAssistant(message))
|
|
213
|
+
return false;
|
|
214
|
+
const completed = completedTimeOf(message);
|
|
215
|
+
if (completed === undefined)
|
|
216
|
+
return false;
|
|
217
|
+
return completed >= startAt && completed <= endAt;
|
|
218
|
+
}
|
|
173
219
|
export function summarizeMessages(entries, startAt = 0, sessionCount = 1, options) {
|
|
174
220
|
const summary = emptyUsageSummary();
|
|
175
221
|
summary.sessionCount = sessionCount;
|
|
176
222
|
for (const entry of entries) {
|
|
177
|
-
if (!
|
|
223
|
+
if (!isCompletedAssistantInRange(entry.info, startAt))
|
|
178
224
|
continue;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
225
|
+
addMessageUsage(summary, entry.info, options);
|
|
226
|
+
}
|
|
227
|
+
return summary;
|
|
228
|
+
}
|
|
229
|
+
export function summarizeMessagesInCompletedRange(entries, startAt, endAt, sessionCount = 1, options) {
|
|
230
|
+
const summary = emptyUsageSummary();
|
|
231
|
+
summary.sessionCount = sessionCount;
|
|
232
|
+
for (const entry of entries) {
|
|
233
|
+
if (!isCompletedAssistantInRange(entry.info, startAt, endAt))
|
|
184
234
|
continue;
|
|
185
235
|
addMessageUsage(summary, entry.info, options);
|
|
186
236
|
}
|
|
@@ -376,9 +426,9 @@ export function mergeUsage(target, source, options) {
|
|
|
376
426
|
target.apiCost += source.apiCost;
|
|
377
427
|
target.assistantMessages += source.assistantMessages;
|
|
378
428
|
target.sessionCount += source.sessionCount;
|
|
379
|
-
const targetBuckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
|
|
380
429
|
const sourceBuckets = source.cacheBuckets;
|
|
381
430
|
if (sourceBuckets) {
|
|
431
|
+
const targetBuckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
|
|
382
432
|
mergeCacheUsageBucket(targetBuckets.readOnly, sourceBuckets.readOnly);
|
|
383
433
|
mergeCacheUsageBucket(targetBuckets.readWrite, sourceBuckets.readWrite);
|
|
384
434
|
}
|