@tmustier/pi-usage-extension 0.3.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +32 -5
- package/data.ts +964 -0
- package/graph.ts +386 -0
- package/index.ts +184 -561
- package/package.json +3 -3
package/data.ts
ADDED
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data collection, caching, and insights for the /usage dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Performance model (see CHANGELOG 0.4.0):
|
|
5
|
+
* - Session JSONL files are scanned at the buffer level. Only lines that can
|
|
6
|
+
* possibly be a session header or an assistant message are UTF-8 decoded and
|
|
7
|
+
* JSON.parsed — the multi-megabyte tool-result lines that dominate session
|
|
8
|
+
* files are skipped with a cheap byte search.
|
|
9
|
+
* - Per-file extraction results are persisted to an on-disk cache keyed by
|
|
10
|
+
* (size, mtimeMs). Session files are append-only, so a warm load only
|
|
11
|
+
* re-parses files that changed since the last run.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// Types
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
export interface TokenStats {
|
|
23
|
+
total: number;
|
|
24
|
+
input: number;
|
|
25
|
+
output: number;
|
|
26
|
+
cacheRead: number;
|
|
27
|
+
cacheWrite: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BaseStats {
|
|
31
|
+
messages: number;
|
|
32
|
+
cost: number;
|
|
33
|
+
tokens: TokenStats;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ModelStats extends BaseStats {
|
|
37
|
+
sessions: Set<string>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ProviderStats extends BaseStats {
|
|
41
|
+
sessions: Set<string>;
|
|
42
|
+
models: Map<string, ModelStats>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface TotalStats extends BaseStats {
|
|
46
|
+
sessions: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Insight {
|
|
50
|
+
percent: number; // 0-100
|
|
51
|
+
headline: string;
|
|
52
|
+
advice: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface PeriodInsights {
|
|
56
|
+
insights: Insight[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface RawMessage {
|
|
60
|
+
sessionId: string;
|
|
61
|
+
timestamp: number;
|
|
62
|
+
cost: number;
|
|
63
|
+
input: number;
|
|
64
|
+
cacheRead: number;
|
|
65
|
+
cacheWrite: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface PeriodRawData {
|
|
69
|
+
messages: RawMessage[];
|
|
70
|
+
sessionCosts: Map<string, number>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface GlobalSessionSpan {
|
|
74
|
+
startMs: number;
|
|
75
|
+
endMs: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface TimeFilteredStats {
|
|
79
|
+
providers: Map<string, ProviderStats>;
|
|
80
|
+
totals: TotalStats;
|
|
81
|
+
insights: PeriodInsights;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One (provider, model, thinkingLevel) cell inside an hourly bucket.
|
|
86
|
+
* Powers the graph explorer; built post-dedupe so it matches table totals.
|
|
87
|
+
*/
|
|
88
|
+
export interface HourlyCell {
|
|
89
|
+
messages: number;
|
|
90
|
+
cost: number;
|
|
91
|
+
input: number;
|
|
92
|
+
output: number;
|
|
93
|
+
cacheRead: number;
|
|
94
|
+
cacheWrite: number;
|
|
95
|
+
reasoning: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Composite key: `${provider}\u0000${model}\u0000${thinkingLevel}` */
|
|
99
|
+
export type HourlyKey = string;
|
|
100
|
+
|
|
101
|
+
export const HOURLY_KEY_SEP = "\u0000";
|
|
102
|
+
|
|
103
|
+
export function makeHourlyKey(provider: string, model: string, thinkingLevel: string): HourlyKey {
|
|
104
|
+
return provider + HOURLY_KEY_SEP + model + HOURLY_KEY_SEP + thinkingLevel;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function splitHourlyKey(key: HourlyKey): { provider: string; model: string; thinkingLevel: string } {
|
|
108
|
+
const [provider = "", model = "", thinkingLevel = ""] = key.split(HOURLY_KEY_SEP);
|
|
109
|
+
return { provider, model, thinkingLevel };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface PeriodBounds {
|
|
113
|
+
todayMs: number;
|
|
114
|
+
weekStartMs: number;
|
|
115
|
+
lastWeekStartMs: number;
|
|
116
|
+
last30DaysStartMs: number;
|
|
117
|
+
nowMs: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface UsageData {
|
|
121
|
+
today: TimeFilteredStats;
|
|
122
|
+
thisWeek: TimeFilteredStats;
|
|
123
|
+
lastWeek: TimeFilteredStats;
|
|
124
|
+
last30Days: TimeFilteredStats;
|
|
125
|
+
allTime: TimeFilteredStats;
|
|
126
|
+
/** Deduped usage bucketed by hour start (ms) → series key → metrics. */
|
|
127
|
+
hourly: Map<number, Map<HourlyKey, HourlyCell>>;
|
|
128
|
+
bounds: PeriodBounds;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type TabName = "today" | "thisWeek" | "lastWeek" | "last30Days" | "allTime";
|
|
132
|
+
|
|
133
|
+
export const TAB_ORDER: TabName[] = ["today", "thisWeek", "lastWeek", "last30Days", "allTime"];
|
|
134
|
+
|
|
135
|
+
export interface SessionMessage {
|
|
136
|
+
provider: string;
|
|
137
|
+
model: string;
|
|
138
|
+
/** Thinking level active when the message was produced; "" when unknown. */
|
|
139
|
+
thinkingLevel: string;
|
|
140
|
+
cost: number;
|
|
141
|
+
input: number;
|
|
142
|
+
output: number;
|
|
143
|
+
cacheRead: number;
|
|
144
|
+
cacheWrite: number;
|
|
145
|
+
/** Reasoning output tokens reported by the provider (0 when absent). */
|
|
146
|
+
reasoning: number;
|
|
147
|
+
timestamp: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface ParsedSessionFile {
|
|
151
|
+
/** Empty string when the file has no session header — such files are ignored. */
|
|
152
|
+
sessionId: string;
|
|
153
|
+
/** Extracted assistant messages, pre-dedupe. Dedupe happens at aggregation. */
|
|
154
|
+
messages: SessionMessage[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// =============================================================================
|
|
158
|
+
// Paths
|
|
159
|
+
// =============================================================================
|
|
160
|
+
|
|
161
|
+
function getAgentDir(): string {
|
|
162
|
+
// Replicate Pi's logic: respect PI_CODING_AGENT_DIR env var
|
|
163
|
+
return process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function getSessionsDir(): string {
|
|
167
|
+
return join(getAgentDir(), "sessions");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function getDefaultCachePath(): string {
|
|
171
|
+
return join(getAgentDir(), "usage-extension-cache.json");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// =============================================================================
|
|
175
|
+
// Session file discovery
|
|
176
|
+
// =============================================================================
|
|
177
|
+
|
|
178
|
+
async function collectSessionFilesRecursively(dir: string, files: string[], signal?: AbortSignal): Promise<void> {
|
|
179
|
+
try {
|
|
180
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
181
|
+
for (const entry of entries) {
|
|
182
|
+
if (signal?.aborted) return;
|
|
183
|
+
const entryPath = join(dir, entry.name);
|
|
184
|
+
if (entry.isDirectory()) {
|
|
185
|
+
await collectSessionFilesRecursively(entryPath, files, signal);
|
|
186
|
+
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
187
|
+
files.push(entryPath);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
// Skip directories we can't read
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function getAllSessionFiles(sessionsDir: string, signal?: AbortSignal): Promise<string[]> {
|
|
196
|
+
const files: string[] = [];
|
|
197
|
+
await collectSessionFilesRecursively(sessionsDir, files, signal);
|
|
198
|
+
files.sort();
|
|
199
|
+
return files;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// =============================================================================
|
|
203
|
+
// Session file parsing
|
|
204
|
+
// =============================================================================
|
|
205
|
+
|
|
206
|
+
const NEWLINE = 0x0a;
|
|
207
|
+
|
|
208
|
+
// Relevance patterns for the buffer-level pre-filter. Pi writes compact JSON
|
|
209
|
+
// (`"role":"assistant"`), but imported/third-party session files have been seen
|
|
210
|
+
// with Python-style spaced JSON (`"role": "assistant"`), so both are matched.
|
|
211
|
+
// False positives (e.g. a tool result quoting one of these strings verbatim)
|
|
212
|
+
// only cost a wasted JSON.parse — the parsed entry is still shape-checked.
|
|
213
|
+
const PATTERN_ASSISTANT_COMPACT = Buffer.from('"role":"assistant"');
|
|
214
|
+
const PATTERN_ASSISTANT_SPACED = Buffer.from('"role": "assistant"');
|
|
215
|
+
const PATTERN_SESSION_COMPACT = Buffer.from('"type":"session"');
|
|
216
|
+
const PATTERN_SESSION_SPACED = Buffer.from('"type": "session"');
|
|
217
|
+
const PATTERN_THINKING_COMPACT = Buffer.from('"type":"thinking_level_change"');
|
|
218
|
+
const PATTERN_THINKING_SPACED = Buffer.from('"type": "thinking_level_change"');
|
|
219
|
+
|
|
220
|
+
const PARSE_YIELD_EVERY_LINES = 2000;
|
|
221
|
+
|
|
222
|
+
function lineMightBeRelevant(line: Buffer): boolean {
|
|
223
|
+
return (
|
|
224
|
+
line.includes(PATTERN_ASSISTANT_COMPACT) ||
|
|
225
|
+
line.includes(PATTERN_SESSION_COMPACT) ||
|
|
226
|
+
line.includes(PATTERN_THINKING_COMPACT) ||
|
|
227
|
+
line.includes(PATTERN_ASSISTANT_SPACED) ||
|
|
228
|
+
line.includes(PATTERN_SESSION_SPACED) ||
|
|
229
|
+
line.includes(PATTERN_THINKING_SPACED)
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Extract the session id and assistant messages from a session JSONL buffer.
|
|
235
|
+
* Returns partial results when aborted — callers must check `signal.aborted`
|
|
236
|
+
* before caching or using the result.
|
|
237
|
+
*/
|
|
238
|
+
export async function parseSessionBuffer(buffer: Buffer, signal?: AbortSignal): Promise<ParsedSessionFile> {
|
|
239
|
+
const messages: SessionMessage[] = [];
|
|
240
|
+
let sessionId = "";
|
|
241
|
+
// Assistant messages don't carry the thinking level; pi records it as separate
|
|
242
|
+
// thinking_level_change entries, always written before the first assistant
|
|
243
|
+
// message of a session. Replaying them in append order attributes each message
|
|
244
|
+
// to the level active when it was produced.
|
|
245
|
+
let thinkingLevel = "";
|
|
246
|
+
let start = 0;
|
|
247
|
+
let lineNumber = 0;
|
|
248
|
+
|
|
249
|
+
while (start < buffer.length) {
|
|
250
|
+
let end = buffer.indexOf(NEWLINE, start);
|
|
251
|
+
if (end === -1) end = buffer.length;
|
|
252
|
+
|
|
253
|
+
lineNumber++;
|
|
254
|
+
if (lineNumber % PARSE_YIELD_EVERY_LINES === 0) {
|
|
255
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
256
|
+
if (signal?.aborted) return { sessionId, messages };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (end > start && lineMightBeRelevant(buffer.subarray(start, end))) {
|
|
260
|
+
try {
|
|
261
|
+
const entry = JSON.parse(buffer.toString("utf8", start, end));
|
|
262
|
+
|
|
263
|
+
if (entry.type === "session") {
|
|
264
|
+
sessionId = entry.id;
|
|
265
|
+
} else if (entry.type === "thinking_level_change") {
|
|
266
|
+
if (typeof entry.thinkingLevel === "string") thinkingLevel = entry.thinkingLevel;
|
|
267
|
+
} else if (entry.type === "message" && entry.message?.role === "assistant") {
|
|
268
|
+
const msg = entry.message;
|
|
269
|
+
if (msg.usage && msg.provider && msg.model) {
|
|
270
|
+
const fallbackTs = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
|
|
271
|
+
messages.push({
|
|
272
|
+
provider: msg.provider,
|
|
273
|
+
model: msg.model,
|
|
274
|
+
thinkingLevel,
|
|
275
|
+
cost: msg.usage.cost?.total || 0,
|
|
276
|
+
input: msg.usage.input || 0,
|
|
277
|
+
output: msg.usage.output || 0,
|
|
278
|
+
cacheRead: msg.usage.cacheRead || 0,
|
|
279
|
+
cacheWrite: msg.usage.cacheWrite || 0,
|
|
280
|
+
reasoning: msg.usage.reasoning || 0,
|
|
281
|
+
timestamp: msg.timestamp || (Number.isNaN(fallbackTs) ? 0 : fallbackTs),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
} catch {
|
|
286
|
+
// Skip malformed lines
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
start = end + 1;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { sessionId, messages };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// =============================================================================
|
|
297
|
+
// On-disk cache
|
|
298
|
+
// =============================================================================
|
|
299
|
+
|
|
300
|
+
const CACHE_VERSION = 2;
|
|
301
|
+
|
|
302
|
+
type CachedMessageTuple = [
|
|
303
|
+
providerIdx: number,
|
|
304
|
+
modelIdx: number,
|
|
305
|
+
cost: number,
|
|
306
|
+
input: number,
|
|
307
|
+
output: number,
|
|
308
|
+
cacheRead: number,
|
|
309
|
+
cacheWrite: number,
|
|
310
|
+
timestamp: number,
|
|
311
|
+
thinkingLevelIdx: number,
|
|
312
|
+
reasoning: number,
|
|
313
|
+
];
|
|
314
|
+
|
|
315
|
+
interface CacheFileEntry {
|
|
316
|
+
size: number;
|
|
317
|
+
mtimeMs: number;
|
|
318
|
+
sessionId: string;
|
|
319
|
+
messages: CachedMessageTuple[];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export interface CachedFileState {
|
|
323
|
+
size: number;
|
|
324
|
+
mtimeMs: number;
|
|
325
|
+
parsed: ParsedSessionFile;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export async function loadUsageCache(cachePath: string): Promise<Map<string, CachedFileState>> {
|
|
329
|
+
const result = new Map<string, CachedFileState>();
|
|
330
|
+
let raw: { version?: unknown; names?: unknown; files?: unknown };
|
|
331
|
+
try {
|
|
332
|
+
raw = JSON.parse(await readFile(cachePath, "utf8"));
|
|
333
|
+
} catch {
|
|
334
|
+
return result; // Missing or corrupt cache — rebuild from scratch.
|
|
335
|
+
}
|
|
336
|
+
if (!raw || raw.version !== CACHE_VERSION || !Array.isArray(raw.names) || typeof raw.files !== "object" || raw.files === null) {
|
|
337
|
+
return result;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const names = raw.names as unknown[];
|
|
341
|
+
for (const [filePath, entry] of Object.entries(raw.files as Record<string, CacheFileEntry>)) {
|
|
342
|
+
if (
|
|
343
|
+
!entry ||
|
|
344
|
+
typeof entry.size !== "number" ||
|
|
345
|
+
typeof entry.mtimeMs !== "number" ||
|
|
346
|
+
typeof entry.sessionId !== "string" ||
|
|
347
|
+
!Array.isArray(entry.messages)
|
|
348
|
+
) {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const messages: SessionMessage[] = [];
|
|
352
|
+
let valid = true;
|
|
353
|
+
for (const tuple of entry.messages) {
|
|
354
|
+
if (!Array.isArray(tuple) || tuple.length !== 10) {
|
|
355
|
+
valid = false;
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
const provider = names[tuple[0]];
|
|
359
|
+
const model = names[tuple[1]];
|
|
360
|
+
const thinkingLevel = names[tuple[8]];
|
|
361
|
+
if (typeof provider !== "string" || typeof model !== "string" || typeof thinkingLevel !== "string") {
|
|
362
|
+
valid = false;
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
messages.push({
|
|
366
|
+
provider,
|
|
367
|
+
model,
|
|
368
|
+
thinkingLevel,
|
|
369
|
+
cost: Number(tuple[2]) || 0,
|
|
370
|
+
input: Number(tuple[3]) || 0,
|
|
371
|
+
output: Number(tuple[4]) || 0,
|
|
372
|
+
cacheRead: Number(tuple[5]) || 0,
|
|
373
|
+
cacheWrite: Number(tuple[6]) || 0,
|
|
374
|
+
timestamp: Number(tuple[7]) || 0,
|
|
375
|
+
reasoning: Number(tuple[9]) || 0,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
if (!valid) continue;
|
|
379
|
+
result.set(filePath, {
|
|
380
|
+
size: entry.size,
|
|
381
|
+
mtimeMs: entry.mtimeMs,
|
|
382
|
+
parsed: { sessionId: entry.sessionId, messages },
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
return result;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export async function saveUsageCache(cachePath: string, states: Map<string, CachedFileState>): Promise<void> {
|
|
389
|
+
const names: string[] = [];
|
|
390
|
+
const nameIndex = new Map<string, number>();
|
|
391
|
+
const intern = (name: string): number => {
|
|
392
|
+
let idx = nameIndex.get(name);
|
|
393
|
+
if (idx === undefined) {
|
|
394
|
+
idx = names.length;
|
|
395
|
+
names.push(name);
|
|
396
|
+
nameIndex.set(name, idx);
|
|
397
|
+
}
|
|
398
|
+
return idx;
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const files: Record<string, CacheFileEntry> = {};
|
|
402
|
+
for (const [filePath, state] of states) {
|
|
403
|
+
files[filePath] = {
|
|
404
|
+
size: state.size,
|
|
405
|
+
mtimeMs: state.mtimeMs,
|
|
406
|
+
sessionId: state.parsed.sessionId,
|
|
407
|
+
messages: state.parsed.messages.map((m): CachedMessageTuple => [
|
|
408
|
+
intern(m.provider),
|
|
409
|
+
intern(m.model),
|
|
410
|
+
m.cost,
|
|
411
|
+
m.input,
|
|
412
|
+
m.output,
|
|
413
|
+
m.cacheRead,
|
|
414
|
+
m.cacheWrite,
|
|
415
|
+
m.timestamp,
|
|
416
|
+
intern(m.thinkingLevel),
|
|
417
|
+
m.reasoning,
|
|
418
|
+
]),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const payload = JSON.stringify({ version: CACHE_VERSION, names, files });
|
|
423
|
+
// Atomic-ish write: concurrent /usage runs race to a last-writer-wins rename
|
|
424
|
+
// instead of interleaving partial writes.
|
|
425
|
+
const tmpPath = join(dirname(cachePath), `.usage-cache-${process.pid}-${Date.now()}.tmp`);
|
|
426
|
+
await writeFile(tmpPath, payload, "utf8");
|
|
427
|
+
await rename(tmpPath, cachePath);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// =============================================================================
|
|
431
|
+
// Aggregation
|
|
432
|
+
// =============================================================================
|
|
433
|
+
|
|
434
|
+
function emptyTokens(): TokenStats {
|
|
435
|
+
return { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function emptyModelStats(): ModelStats {
|
|
439
|
+
return { sessions: new Set(), messages: 0, cost: 0, tokens: emptyTokens() };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function emptyProviderStats(): ProviderStats {
|
|
443
|
+
return { sessions: new Set(), messages: 0, cost: 0, tokens: emptyTokens(), models: new Map() };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function emptyTimeFilteredStats(): TimeFilteredStats {
|
|
447
|
+
return {
|
|
448
|
+
providers: new Map(),
|
|
449
|
+
totals: { sessions: 0, messages: 0, cost: 0, tokens: emptyTokens() },
|
|
450
|
+
insights: { insights: [] },
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function emptyPeriodRawData(): PeriodRawData {
|
|
455
|
+
return { messages: [], sessionCosts: new Map() };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function emptyUsageData(bounds: PeriodBounds): UsageData {
|
|
459
|
+
return {
|
|
460
|
+
today: emptyTimeFilteredStats(),
|
|
461
|
+
thisWeek: emptyTimeFilteredStats(),
|
|
462
|
+
lastWeek: emptyTimeFilteredStats(),
|
|
463
|
+
last30Days: emptyTimeFilteredStats(),
|
|
464
|
+
allTime: emptyTimeFilteredStats(),
|
|
465
|
+
hourly: new Map(),
|
|
466
|
+
bounds,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const HOUR_MS = 3_600_000;
|
|
471
|
+
|
|
472
|
+
function addToHourlyBuckets(hourly: Map<number, Map<HourlyKey, HourlyCell>>, msg: SessionMessage): void {
|
|
473
|
+
if (msg.timestamp <= 0) return; // Unknown time can't be placed on a time axis.
|
|
474
|
+
const hour = Math.floor(msg.timestamp / HOUR_MS) * HOUR_MS;
|
|
475
|
+
let bucket = hourly.get(hour);
|
|
476
|
+
if (!bucket) {
|
|
477
|
+
bucket = new Map();
|
|
478
|
+
hourly.set(hour, bucket);
|
|
479
|
+
}
|
|
480
|
+
const key = makeHourlyKey(msg.provider, msg.model, msg.thinkingLevel);
|
|
481
|
+
let cell = bucket.get(key);
|
|
482
|
+
if (!cell) {
|
|
483
|
+
cell = { messages: 0, cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 };
|
|
484
|
+
bucket.set(key, cell);
|
|
485
|
+
}
|
|
486
|
+
cell.messages++;
|
|
487
|
+
cell.cost += msg.cost;
|
|
488
|
+
cell.input += msg.input;
|
|
489
|
+
cell.output += msg.output;
|
|
490
|
+
cell.cacheRead += msg.cacheRead;
|
|
491
|
+
cell.cacheWrite += msg.cacheWrite;
|
|
492
|
+
cell.reasoning += msg.reasoning;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Helper to accumulate stats into a target
|
|
496
|
+
function accumulateStats(
|
|
497
|
+
target: BaseStats,
|
|
498
|
+
cost: number,
|
|
499
|
+
tokens: { total: number; input: number; output: number; cacheRead: number; cacheWrite: number }
|
|
500
|
+
): void {
|
|
501
|
+
target.messages++;
|
|
502
|
+
target.cost += cost;
|
|
503
|
+
target.tokens.total += tokens.total;
|
|
504
|
+
target.tokens.input += tokens.input;
|
|
505
|
+
target.tokens.output += tokens.output;
|
|
506
|
+
target.tokens.cacheRead += tokens.cacheRead;
|
|
507
|
+
target.tokens.cacheWrite += tokens.cacheWrite;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function getPeriodsForTimestamp(
|
|
511
|
+
timestamp: number,
|
|
512
|
+
todayMs: number,
|
|
513
|
+
weekStartMs: number,
|
|
514
|
+
lastWeekStartMs: number,
|
|
515
|
+
last30DaysStartMs: number
|
|
516
|
+
): TabName[] {
|
|
517
|
+
const periods: TabName[] = ["allTime"];
|
|
518
|
+
if (timestamp >= todayMs) periods.push("today");
|
|
519
|
+
if (timestamp >= weekStartMs) {
|
|
520
|
+
periods.push("thisWeek");
|
|
521
|
+
} else if (timestamp >= lastWeekStartMs) {
|
|
522
|
+
periods.push("lastWeek");
|
|
523
|
+
}
|
|
524
|
+
if (timestamp >= last30DaysStartMs) periods.push("last30Days");
|
|
525
|
+
return periods;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function addMessagesToUsageData(
|
|
529
|
+
data: UsageData,
|
|
530
|
+
sessionId: string,
|
|
531
|
+
messages: SessionMessage[],
|
|
532
|
+
todayMs: number,
|
|
533
|
+
weekStartMs: number,
|
|
534
|
+
lastWeekStartMs: number,
|
|
535
|
+
last30DaysStartMs: number,
|
|
536
|
+
rawByPeriod: Record<TabName, PeriodRawData>,
|
|
537
|
+
globalSessionSpans: Map<string, GlobalSessionSpan>
|
|
538
|
+
): void {
|
|
539
|
+
const sessionContributed = { today: false, thisWeek: false, lastWeek: false, last30Days: false, allTime: false };
|
|
540
|
+
|
|
541
|
+
for (const msg of messages) {
|
|
542
|
+
// Track real per-session lifetime across every message we see, regardless of
|
|
543
|
+
// which period the message falls into. Used later for the "8h+ session" insight.
|
|
544
|
+
if (msg.timestamp > 0) {
|
|
545
|
+
const span = globalSessionSpans.get(sessionId);
|
|
546
|
+
if (!span) {
|
|
547
|
+
globalSessionSpans.set(sessionId, { startMs: msg.timestamp, endMs: msg.timestamp });
|
|
548
|
+
} else {
|
|
549
|
+
if (msg.timestamp < span.startMs) span.startMs = msg.timestamp;
|
|
550
|
+
if (msg.timestamp > span.endMs) span.endMs = msg.timestamp;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
addToHourlyBuckets(data.hourly, msg);
|
|
555
|
+
|
|
556
|
+
const periods = getPeriodsForTimestamp(msg.timestamp, todayMs, weekStartMs, lastWeekStartMs, last30DaysStartMs);
|
|
557
|
+
const tokens = {
|
|
558
|
+
// Count fresh tokens processed this turn.
|
|
559
|
+
// Include cacheWrite because those prompt tokens were newly written and billed.
|
|
560
|
+
// Exclude cacheRead because repeated cache hits would otherwise dominate totals.
|
|
561
|
+
total: msg.input + msg.output + msg.cacheWrite,
|
|
562
|
+
input: msg.input,
|
|
563
|
+
output: msg.output,
|
|
564
|
+
cacheRead: msg.cacheRead,
|
|
565
|
+
cacheWrite: msg.cacheWrite,
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
for (const period of periods) {
|
|
569
|
+
const stats = data[period];
|
|
570
|
+
|
|
571
|
+
let providerStats = stats.providers.get(msg.provider);
|
|
572
|
+
if (!providerStats) {
|
|
573
|
+
providerStats = emptyProviderStats();
|
|
574
|
+
stats.providers.set(msg.provider, providerStats);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
let modelStats = providerStats.models.get(msg.model);
|
|
578
|
+
if (!modelStats) {
|
|
579
|
+
modelStats = emptyModelStats();
|
|
580
|
+
providerStats.models.set(msg.model, modelStats);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
modelStats.sessions.add(sessionId);
|
|
584
|
+
accumulateStats(modelStats, msg.cost, tokens);
|
|
585
|
+
|
|
586
|
+
providerStats.sessions.add(sessionId);
|
|
587
|
+
accumulateStats(providerStats, msg.cost, tokens);
|
|
588
|
+
|
|
589
|
+
accumulateStats(stats.totals, msg.cost, tokens);
|
|
590
|
+
sessionContributed[period] = true;
|
|
591
|
+
|
|
592
|
+
const raw = rawByPeriod[period];
|
|
593
|
+
raw.messages.push({
|
|
594
|
+
sessionId,
|
|
595
|
+
timestamp: msg.timestamp,
|
|
596
|
+
cost: msg.cost,
|
|
597
|
+
input: msg.input,
|
|
598
|
+
cacheRead: msg.cacheRead,
|
|
599
|
+
cacheWrite: msg.cacheWrite,
|
|
600
|
+
});
|
|
601
|
+
raw.sessionCosts.set(sessionId, (raw.sessionCosts.get(sessionId) ?? 0) + msg.cost);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (sessionContributed.today) data.today.totals.sessions++;
|
|
606
|
+
if (sessionContributed.thisWeek) data.thisWeek.totals.sessions++;
|
|
607
|
+
if (sessionContributed.lastWeek) data.lastWeek.totals.sessions++;
|
|
608
|
+
if (sessionContributed.last30Days) data.last30Days.totals.sessions++;
|
|
609
|
+
if (sessionContributed.allTime) data.allTime.totals.sessions++;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// =============================================================================
|
|
613
|
+
// Collection orchestration
|
|
614
|
+
// =============================================================================
|
|
615
|
+
|
|
616
|
+
const STAT_CONCURRENCY = 16;
|
|
617
|
+
const DEFAULT_PARSE_CONCURRENCY = 4;
|
|
618
|
+
const AGGREGATE_YIELD_EVERY_FILES = 200;
|
|
619
|
+
|
|
620
|
+
export interface CollectUsageOptions {
|
|
621
|
+
signal?: AbortSignal;
|
|
622
|
+
/** Defaults to `<agentDir>/sessions`. */
|
|
623
|
+
sessionsDir?: string;
|
|
624
|
+
/** Defaults to `<agentDir>/usage-extension-cache.json`. Pass `null` to disable the on-disk cache. */
|
|
625
|
+
cachePath?: string | null;
|
|
626
|
+
/** Reference time for period bucketing. Defaults to `new Date()`. */
|
|
627
|
+
now?: Date;
|
|
628
|
+
parseConcurrency?: number;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export async function collectUsageData(options: CollectUsageOptions = {}): Promise<UsageData | null> {
|
|
632
|
+
const signal = options.signal;
|
|
633
|
+
const now = options.now ?? new Date();
|
|
634
|
+
const sessionsDir = options.sessionsDir ?? getSessionsDir();
|
|
635
|
+
const cachePath = options.cachePath === undefined ? getDefaultCachePath() : options.cachePath;
|
|
636
|
+
const parseConcurrency = Math.max(1, options.parseConcurrency ?? DEFAULT_PARSE_CONCURRENCY);
|
|
637
|
+
|
|
638
|
+
const startOfToday = new Date(now);
|
|
639
|
+
startOfToday.setHours(0, 0, 0, 0);
|
|
640
|
+
const todayMs = startOfToday.getTime();
|
|
641
|
+
|
|
642
|
+
// Start of current week (Monday 00:00)
|
|
643
|
+
const startOfWeek = new Date(now);
|
|
644
|
+
const dayOfWeek = startOfWeek.getDay(); // 0 = Sunday, 1 = Monday, ...
|
|
645
|
+
const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
|
646
|
+
startOfWeek.setDate(startOfWeek.getDate() - daysSinceMonday);
|
|
647
|
+
startOfWeek.setHours(0, 0, 0, 0);
|
|
648
|
+
const weekStartMs = startOfWeek.getTime();
|
|
649
|
+
|
|
650
|
+
// Start of last week (previous Monday 00:00)
|
|
651
|
+
const startOfLastWeek = new Date(startOfWeek);
|
|
652
|
+
startOfLastWeek.setDate(startOfLastWeek.getDate() - 7);
|
|
653
|
+
const lastWeekStartMs = startOfLastWeek.getTime();
|
|
654
|
+
|
|
655
|
+
// Rolling 30-day window: the last 30 calendar days including today,
|
|
656
|
+
// i.e. from midnight 29 days before today. setDate handles DST correctly.
|
|
657
|
+
const startOfLast30Days = new Date(startOfToday);
|
|
658
|
+
startOfLast30Days.setDate(startOfLast30Days.getDate() - 29);
|
|
659
|
+
const last30DaysStartMs = startOfLast30Days.getTime();
|
|
660
|
+
|
|
661
|
+
// 1. Discover session files.
|
|
662
|
+
const filePaths = await getAllSessionFiles(sessionsDir, signal);
|
|
663
|
+
if (signal?.aborted) return null;
|
|
664
|
+
|
|
665
|
+
// 2. Stat them (batched) so cache freshness can be checked without reading contents.
|
|
666
|
+
const fileStats = new Map<string, { size: number; mtimeMs: number }>();
|
|
667
|
+
{
|
|
668
|
+
let next = 0;
|
|
669
|
+
await Promise.all(
|
|
670
|
+
Array.from({ length: STAT_CONCURRENCY }, async () => {
|
|
671
|
+
while (next < filePaths.length) {
|
|
672
|
+
if (signal?.aborted) return;
|
|
673
|
+
const filePath = filePaths[next++]!;
|
|
674
|
+
try {
|
|
675
|
+
const st = await stat(filePath);
|
|
676
|
+
fileStats.set(filePath, { size: st.size, mtimeMs: st.mtimeMs });
|
|
677
|
+
} catch {
|
|
678
|
+
// File vanished between listing and stat — skip it.
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
})
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
if (signal?.aborted) return null;
|
|
685
|
+
|
|
686
|
+
// 3. Load the cache and decide which files actually need parsing.
|
|
687
|
+
const previous = cachePath ? await loadUsageCache(cachePath) : new Map<string, CachedFileState>();
|
|
688
|
+
if (signal?.aborted) return null;
|
|
689
|
+
const current = new Map<string, CachedFileState>();
|
|
690
|
+
const toParse: string[] = [];
|
|
691
|
+
for (const filePath of filePaths) {
|
|
692
|
+
const st = fileStats.get(filePath);
|
|
693
|
+
if (!st) continue;
|
|
694
|
+
const cached = previous.get(filePath);
|
|
695
|
+
if (cached && cached.size === st.size && cached.mtimeMs === st.mtimeMs) {
|
|
696
|
+
current.set(filePath, cached);
|
|
697
|
+
} else {
|
|
698
|
+
toParse.push(filePath);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
let dirty = toParse.length > 0;
|
|
702
|
+
if (!dirty) {
|
|
703
|
+
for (const filePath of previous.keys()) {
|
|
704
|
+
if (!fileStats.has(filePath)) {
|
|
705
|
+
dirty = true; // A cached file was deleted — evict it by rewriting.
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// 4. Parse new/changed files with bounded concurrency.
|
|
712
|
+
{
|
|
713
|
+
let next = 0;
|
|
714
|
+
await Promise.all(
|
|
715
|
+
Array.from({ length: parseConcurrency }, async () => {
|
|
716
|
+
while (next < toParse.length) {
|
|
717
|
+
if (signal?.aborted) return;
|
|
718
|
+
const filePath = toParse[next++]!;
|
|
719
|
+
const st = fileStats.get(filePath)!;
|
|
720
|
+
let buffer: Buffer;
|
|
721
|
+
try {
|
|
722
|
+
buffer = await readFile(filePath);
|
|
723
|
+
} catch {
|
|
724
|
+
continue; // File vanished — skip it.
|
|
725
|
+
}
|
|
726
|
+
const parsed = await parseSessionBuffer(buffer, signal);
|
|
727
|
+
if (signal?.aborted) return; // Never cache a partial parse.
|
|
728
|
+
current.set(filePath, { size: st.size, mtimeMs: st.mtimeMs, parsed });
|
|
729
|
+
}
|
|
730
|
+
})
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (signal?.aborted) {
|
|
735
|
+
// Best effort: persist whatever finished so a cancelled cold build makes
|
|
736
|
+
// the next attempt cheaper. Keep old entries for files not processed yet —
|
|
737
|
+
// they are re-validated against size/mtime next run anyway.
|
|
738
|
+
if (cachePath && dirty && current.size > 0) {
|
|
739
|
+
const merged = new Map(previous);
|
|
740
|
+
for (const [filePath, state] of current) merged.set(filePath, state);
|
|
741
|
+
await saveUsageCache(cachePath, merged).catch(() => {});
|
|
742
|
+
}
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// 5. Persist the refreshed cache (also evicts entries for deleted files).
|
|
747
|
+
if (cachePath && dirty) {
|
|
748
|
+
await saveUsageCache(cachePath, current).catch(() => {
|
|
749
|
+
// Cache write failures must never break /usage.
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// 6. Aggregate in sorted path order with cross-file dedupe.
|
|
754
|
+
const data = emptyUsageData({ todayMs, weekStartMs, lastWeekStartMs, last30DaysStartMs, nowMs: now.getTime() });
|
|
755
|
+
const rawByPeriod: Record<TabName, PeriodRawData> = {
|
|
756
|
+
today: emptyPeriodRawData(),
|
|
757
|
+
thisWeek: emptyPeriodRawData(),
|
|
758
|
+
lastWeek: emptyPeriodRawData(),
|
|
759
|
+
last30Days: emptyPeriodRawData(),
|
|
760
|
+
allTime: emptyPeriodRawData(),
|
|
761
|
+
};
|
|
762
|
+
const globalSessionSpans = new Map<string, GlobalSessionSpan>();
|
|
763
|
+
const seenHashes = new Set<string>();
|
|
764
|
+
let processedFiles = 0;
|
|
765
|
+
|
|
766
|
+
for (const filePath of filePaths) {
|
|
767
|
+
const state = current.get(filePath);
|
|
768
|
+
if (!state || !state.parsed.sessionId) continue;
|
|
769
|
+
|
|
770
|
+
if (++processedFiles % AGGREGATE_YIELD_EVERY_FILES === 0) {
|
|
771
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
772
|
+
if (signal?.aborted) return null;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Deduplicate copied history across branched session files.
|
|
776
|
+
// Keep the existing ccusage-style hash so current totals remain comparable.
|
|
777
|
+
const deduped: SessionMessage[] = [];
|
|
778
|
+
for (const m of state.parsed.messages) {
|
|
779
|
+
const hash = `${m.timestamp}:${m.input + m.output + m.cacheRead + m.cacheWrite}`;
|
|
780
|
+
if (seenHashes.has(hash)) continue;
|
|
781
|
+
seenHashes.add(hash);
|
|
782
|
+
deduped.push(m);
|
|
783
|
+
}
|
|
784
|
+
if (deduped.length === 0) continue;
|
|
785
|
+
|
|
786
|
+
addMessagesToUsageData(
|
|
787
|
+
data,
|
|
788
|
+
state.parsed.sessionId,
|
|
789
|
+
deduped,
|
|
790
|
+
todayMs,
|
|
791
|
+
weekStartMs,
|
|
792
|
+
lastWeekStartMs,
|
|
793
|
+
last30DaysStartMs,
|
|
794
|
+
rawByPeriod,
|
|
795
|
+
globalSessionSpans
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Classify sessions that are globally long-running once, then reuse across periods.
|
|
800
|
+
const longSessionIds = new Set<string>();
|
|
801
|
+
for (const [id, span] of globalSessionSpans) {
|
|
802
|
+
if (span.endMs - span.startMs >= LONG_SESSION_MS) longSessionIds.add(id);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
for (const period of TAB_ORDER) {
|
|
806
|
+
data[period].insights = computeInsights(rawByPeriod[period], longSessionIds);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
return data;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// =============================================================================
|
|
813
|
+
// Insights
|
|
814
|
+
// =============================================================================
|
|
815
|
+
|
|
816
|
+
const PARALLEL_WINDOW_MS = 2 * 60_000; // exact ±N milliseconds around each message
|
|
817
|
+
const PARALLEL_SESSION_THRESHOLD = 4;
|
|
818
|
+
const LARGE_CONTEXT_THRESHOLD = 150_000;
|
|
819
|
+
const LARGE_CACHE_MISS_THRESHOLD = 100_000;
|
|
820
|
+
const LONG_SESSION_MS = 8 * 60 * 60 * 1000;
|
|
821
|
+
const TOP_SESSION_COUNT = 5;
|
|
822
|
+
const MIN_MESSAGES_FOR_PARALLEL_INSIGHT = 10;
|
|
823
|
+
const MIN_PERCENT_TO_SHOW = 1;
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Insights are weighted by recorded API cost. Periods with zero total cost produce
|
|
827
|
+
* an empty `insights` list — the UI renders a distinct empty-state for that case.
|
|
828
|
+
* Long-running-session classification is passed in from a global pass so that a
|
|
829
|
+
* session's real lifetime is used rather than the slice visible inside this period.
|
|
830
|
+
*/
|
|
831
|
+
function computeInsights(raw: PeriodRawData, longSessionIds: Set<string>): PeriodInsights {
|
|
832
|
+
if (raw.messages.length === 0) {
|
|
833
|
+
return { insights: [] };
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const total = raw.messages.reduce((sum, m) => sum + m.cost, 0);
|
|
837
|
+
if (total <= 0) {
|
|
838
|
+
return { insights: [] };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const candidates: Insight[] = [];
|
|
842
|
+
|
|
843
|
+
// 1. Parallel sessions — ≥ N unique sessions active within an exact ±W ms window.
|
|
844
|
+
const parallelWeight = computeParallelCostWeight(raw.messages);
|
|
845
|
+
if (parallelWeight !== null) {
|
|
846
|
+
candidates.push({
|
|
847
|
+
percent: (parallelWeight / total) * 100,
|
|
848
|
+
headline: `of your cost was while ${PARALLEL_SESSION_THRESHOLD}+ sessions ran in parallel`,
|
|
849
|
+
advice:
|
|
850
|
+
"All sessions share one rate limit. If you don't need them all at once, queueing uses capacity more evenly.",
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// 2. Large context — input + cacheRead + cacheWrite > threshold.
|
|
855
|
+
const largeContextWeight = raw.messages
|
|
856
|
+
.filter((m) => m.input + m.cacheRead + m.cacheWrite > LARGE_CONTEXT_THRESHOLD)
|
|
857
|
+
.reduce((sum, m) => sum + m.cost, 0);
|
|
858
|
+
candidates.push({
|
|
859
|
+
percent: (largeContextWeight / total) * 100,
|
|
860
|
+
headline: `of your cost was at >${formatThresholdTokens(LARGE_CONTEXT_THRESHOLD)} context`,
|
|
861
|
+
advice:
|
|
862
|
+
"Longer sessions are more expensive even when cached. /compact mid-task, /clear when switching to new tasks.",
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
// 3. Large uncached prompt — fresh (non-cached) input > threshold, per the v0.2.0 formula.
|
|
866
|
+
const uncachedWeight = raw.messages
|
|
867
|
+
.filter((m) => m.input + m.cacheWrite > LARGE_CACHE_MISS_THRESHOLD)
|
|
868
|
+
.reduce((sum, m) => sum + m.cost, 0);
|
|
869
|
+
candidates.push({
|
|
870
|
+
percent: (uncachedWeight / total) * 100,
|
|
871
|
+
headline: `of your cost came from >${formatThresholdTokens(LARGE_CACHE_MISS_THRESHOLD)}-token uncached prompts`,
|
|
872
|
+
advice:
|
|
873
|
+
"Uncached input is expensive, and often happens when sending a message to a session that has gone idle. /compact before stepping away keeps the cold-start small.",
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
// 4. Long-running sessions — classification comes from the global pass so we use
|
|
877
|
+
// true session lifetime, not just the span visible inside this period slice.
|
|
878
|
+
const longWeight = raw.messages
|
|
879
|
+
.filter((m) => longSessionIds.has(m.sessionId))
|
|
880
|
+
.reduce((sum, m) => sum + m.cost, 0);
|
|
881
|
+
if (longWeight > 0) {
|
|
882
|
+
candidates.push({
|
|
883
|
+
percent: (longWeight / total) * 100,
|
|
884
|
+
headline: `of your cost came from sessions active for ${LONG_SESSION_MS / 3_600_000}+ hours`,
|
|
885
|
+
advice:
|
|
886
|
+
"These are often background/loop sessions. Continuous usage can add up quickly so make sure it is intentional.",
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// 5. Top-N session concentration.
|
|
891
|
+
if (raw.sessionCosts.size > TOP_SESSION_COUNT) {
|
|
892
|
+
const sortedSessions = Array.from(raw.sessionCosts.values()).sort((a, b) => b - a);
|
|
893
|
+
const topN = Math.min(TOP_SESSION_COUNT, sortedSessions.length);
|
|
894
|
+
const topWeight = sortedSessions.slice(0, topN).reduce((sum, c) => sum + c, 0);
|
|
895
|
+
candidates.push({
|
|
896
|
+
percent: (topWeight / total) * 100,
|
|
897
|
+
headline: `of your cost came from your top ${topN} session${topN === 1 ? "" : "s"}`,
|
|
898
|
+
advice:
|
|
899
|
+
"A small number of sessions drives most of your spend. The table view can help pinpoint which ones.",
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const insights = candidates.filter((i) => i.percent >= MIN_PERCENT_TO_SHOW).sort((a, b) => b.percent - a.percent);
|
|
904
|
+
return { insights };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Two-pointer sweep of messages sorted by timestamp. For each message, count the
|
|
909
|
+
* number of distinct session IDs whose messages fall within an exact ± window.
|
|
910
|
+
* Returns the total cost attributable to moments when ≥ threshold sessions were
|
|
911
|
+
* active, or null if the period has too few sessions/messages to call it.
|
|
912
|
+
*
|
|
913
|
+
* Messages with missing/invalid timestamps (parsed as 0) are filtered out first —
|
|
914
|
+
* otherwise they would collapse into a single synthetic instant and inflate the
|
|
915
|
+
* parallel count on older or incomplete logs.
|
|
916
|
+
*/
|
|
917
|
+
function computeParallelCostWeight(messages: RawMessage[]): number | null {
|
|
918
|
+
const timed = messages.filter((m) => m.timestamp > 0);
|
|
919
|
+
if (timed.length < MIN_MESSAGES_FOR_PARALLEL_INSIGHT) return null;
|
|
920
|
+
const distinctSessions = new Set(timed.map((m) => m.sessionId));
|
|
921
|
+
if (distinctSessions.size < PARALLEL_SESSION_THRESHOLD) return null;
|
|
922
|
+
|
|
923
|
+
const sorted = timed.slice().sort((a, b) => a.timestamp - b.timestamp);
|
|
924
|
+
const sidCount = new Map<string, number>();
|
|
925
|
+
let uniqueCount = 0;
|
|
926
|
+
let left = 0;
|
|
927
|
+
let right = 0;
|
|
928
|
+
let parallelCost = 0;
|
|
929
|
+
|
|
930
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
931
|
+
const current = sorted[i]!;
|
|
932
|
+
const high = current.timestamp + PARALLEL_WINDOW_MS;
|
|
933
|
+
const low = current.timestamp - PARALLEL_WINDOW_MS;
|
|
934
|
+
|
|
935
|
+
while (right < sorted.length && sorted[right]!.timestamp <= high) {
|
|
936
|
+
const sid = sorted[right]!.sessionId;
|
|
937
|
+
const next = (sidCount.get(sid) ?? 0) + 1;
|
|
938
|
+
sidCount.set(sid, next);
|
|
939
|
+
if (next === 1) uniqueCount++;
|
|
940
|
+
right++;
|
|
941
|
+
}
|
|
942
|
+
while (left < right && sorted[left]!.timestamp < low) {
|
|
943
|
+
const sid = sorted[left]!.sessionId;
|
|
944
|
+
const next = (sidCount.get(sid) ?? 0) - 1;
|
|
945
|
+
if (next === 0) {
|
|
946
|
+
sidCount.delete(sid);
|
|
947
|
+
uniqueCount--;
|
|
948
|
+
} else {
|
|
949
|
+
sidCount.set(sid, next);
|
|
950
|
+
}
|
|
951
|
+
left++;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
if (uniqueCount >= PARALLEL_SESSION_THRESHOLD) parallelCost += current.cost;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
return parallelCost;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function formatThresholdTokens(n: number): string {
|
|
961
|
+
if (n >= 1_000_000) return `${n / 1_000_000}M`;
|
|
962
|
+
if (n >= 1_000) return `${n / 1_000}k`;
|
|
963
|
+
return String(n);
|
|
964
|
+
}
|