@aaroncarry/pi-usage 0.1.0 → 0.2.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/README.md +30 -6
- package/README.zh-CN.md +28 -5
- package/package.json +10 -3
- package/src/config.ts +3 -0
- package/src/index.ts +144 -7
- package/src/trends/aggregate.ts +822 -0
- package/src/trends/dashboard.ts +364 -0
- package/src/trends/insights.ts +152 -0
- package/src/trends/render.ts +393 -0
- package/src/ui/card.ts +39 -1
- package/src/ui/statusline.ts +5 -2
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trends aggregation: scan pi session JSONL files and build hourly usage
|
|
3
|
+
* buckets. Accounting mirrors pi's footer: assistant messages, tool results
|
|
4
|
+
* with nested usage, compaction and branch summaries all count; auxiliary
|
|
5
|
+
* records (tools/summaries) are grouped under "Tools / summaries".
|
|
6
|
+
*
|
|
7
|
+
* Token metric convention (same as tmustier's usage extension):
|
|
8
|
+
* tokens = input + output + cacheWrite (fresh tokens; cacheRead excluded
|
|
9
|
+
* so cache hits do not drown the chart).
|
|
10
|
+
*
|
|
11
|
+
* A disk cache keyed by file size+mtime makes repeat scans incremental.
|
|
12
|
+
* Forked session copies are deduplicated across files by
|
|
13
|
+
* sourceId:timestamp:token-sum fingerprints (the session header id survives
|
|
14
|
+
* copying, the file path does not).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFile, readdir, stat, writeFile, rename } from "node:fs/promises";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
export interface TrendCell {
|
|
21
|
+
messages: number;
|
|
22
|
+
cost: number;
|
|
23
|
+
input: number;
|
|
24
|
+
output: number;
|
|
25
|
+
cacheRead: number;
|
|
26
|
+
cacheWrite: number;
|
|
27
|
+
/** Thinking tokens reported by the provider (assistant messages only). */
|
|
28
|
+
reasoning: number;
|
|
29
|
+
/** Likely cache misses detected on this bucket's assistant messages. */
|
|
30
|
+
missCount: number;
|
|
31
|
+
/** Cost of the cache-miss messages themselves. */
|
|
32
|
+
missCost: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TrendsData {
|
|
36
|
+
/** hourStart (epoch ms, UTC hour) → "provider${KEY_SEP}model" → cell */
|
|
37
|
+
hourly: Map<number, Map<string, TrendCell>>;
|
|
38
|
+
/** "provider${KEY_SEP}model" → display names */
|
|
39
|
+
keys: Map<string, { provider: string; model: string }>;
|
|
40
|
+
/** "provider${KEY_SEP}model" → contributing session source ids */
|
|
41
|
+
sessions: Map<string, Set<string>>;
|
|
42
|
+
/** All session source ids that contributed at least one assistant message. */
|
|
43
|
+
totalSessions: Set<string>;
|
|
44
|
+
/** Session source id → non-auxiliary cost, for spend-concentration insights. */
|
|
45
|
+
sessionCost: Map<string, number>;
|
|
46
|
+
/** hourStart → "project${KEY_SEP}provider${KEY_SEP}model" → cell */
|
|
47
|
+
hourlyProject: Map<number, Map<string, TrendCell>>;
|
|
48
|
+
/** "project${KEY_SEP}provider${KEY_SEP}model" → names */
|
|
49
|
+
projectKeys: Map<string, { project: string; provider: string; model: string }>;
|
|
50
|
+
/** "project${KEY_SEP}provider${KEY_SEP}model" → contributing session source ids */
|
|
51
|
+
projectSessions: Map<string, Set<string>>;
|
|
52
|
+
generatedAt: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Zero-character join key for composite map keys (never appears in real names). */
|
|
56
|
+
const KEY_SEP = String.fromCharCode(0);
|
|
57
|
+
|
|
58
|
+
export const AUXILIARY_PROVIDER = "Tools";
|
|
59
|
+
export const AUXILIARY_MODEL = "summaries";
|
|
60
|
+
const AUXILIARY_KEY = `${AUXILIARY_PROVIDER}${KEY_SEP}${AUXILIARY_MODEL}`;
|
|
61
|
+
const EXCLUDED_PROVIDERS = new Set(["faux-provider", "fake-provider"]);
|
|
62
|
+
const HOUR_MS = 3_600_000;
|
|
63
|
+
const CACHE_VERSION = 3;
|
|
64
|
+
|
|
65
|
+
export const TREND_METRICS = ["tokens", "cost"] as const;
|
|
66
|
+
export type TrendMetric = (typeof TREND_METRICS)[number];
|
|
67
|
+
|
|
68
|
+
export const TREND_PERIODS = ["7d", "30d", "90d", "all"] as const;
|
|
69
|
+
export type TrendPeriod = (typeof TREND_PERIODS)[number];
|
|
70
|
+
|
|
71
|
+
/** Short project label: last path segment of the session cwd. */
|
|
72
|
+
export function projectLabel(cwd: string): string {
|
|
73
|
+
const trimmed = cwd.replace(/[\\/]+$/, "");
|
|
74
|
+
const segments = trimmed.split(/[\\/]/);
|
|
75
|
+
return segments[segments.length - 1] || "unknown";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Display labels for model rows: bare model id unless the same id exists
|
|
80
|
+
* under multiple providers, then "model (provider)" to disambiguate.
|
|
81
|
+
*/
|
|
82
|
+
export function annotateModelLabels<T extends { provider: string; model: string }>(
|
|
83
|
+
rows: T[],
|
|
84
|
+
): (T & { label: string })[] {
|
|
85
|
+
const counts = new Map<string, number>();
|
|
86
|
+
for (const row of rows) counts.set(row.model, (counts.get(row.model) ?? 0) + 1);
|
|
87
|
+
return rows.map((row) => ({
|
|
88
|
+
...row,
|
|
89
|
+
label: (counts.get(row.model) ?? 0) > 1 ? `${row.model} (${row.provider})` : row.model,
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
94
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface UsageTuple {
|
|
98
|
+
cost: number;
|
|
99
|
+
input: number;
|
|
100
|
+
output: number;
|
|
101
|
+
cacheRead: number;
|
|
102
|
+
cacheWrite: number;
|
|
103
|
+
reasoning: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Cache-miss classification: 0 none, 1 session gap, 2 model switch, 3 mid-session. */
|
|
107
|
+
type CacheMissKind = 0 | 1 | 2 | 3;
|
|
108
|
+
|
|
109
|
+
function readUsage(usage: unknown): UsageTuple | undefined {
|
|
110
|
+
if (!isRecord(usage)) return undefined;
|
|
111
|
+
const costValue = usage.cost;
|
|
112
|
+
const cost = typeof costValue === "number" ? costValue : isRecord(costValue) ? toNumber(costValue.total) ?? 0 : 0;
|
|
113
|
+
const tuple = {
|
|
114
|
+
cost,
|
|
115
|
+
input: toNumber(usage.input) ?? 0,
|
|
116
|
+
output: toNumber(usage.output) ?? 0,
|
|
117
|
+
cacheRead: toNumber(usage.cacheRead) ?? 0,
|
|
118
|
+
cacheWrite: toNumber(usage.cacheWrite) ?? 0,
|
|
119
|
+
reasoning: toNumber(usage.reasoning) ?? 0,
|
|
120
|
+
};
|
|
121
|
+
const sum = tuple.input + tuple.output + tuple.cacheRead + tuple.cacheWrite;
|
|
122
|
+
if (sum === 0 && tuple.cost === 0) return undefined;
|
|
123
|
+
return tuple;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function toNumber(value: unknown): number | undefined {
|
|
127
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
128
|
+
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) return Number(value);
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Fingerprint shared by duplicate copies of the same record; full field tuple to avoid collisions. */
|
|
133
|
+
function fingerprint(tuple: UsageTuple): string {
|
|
134
|
+
return `${tuple.input}:${tuple.output}:${tuple.cacheRead}:${tuple.cacheWrite}:${tuple.cost}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface ParsedRecord {
|
|
138
|
+
sourceId: string;
|
|
139
|
+
provider: string;
|
|
140
|
+
model: string;
|
|
141
|
+
timestamp: number;
|
|
142
|
+
cost: number;
|
|
143
|
+
input: number;
|
|
144
|
+
output: number;
|
|
145
|
+
cacheRead: number;
|
|
146
|
+
cacheWrite: number;
|
|
147
|
+
reasoning: number;
|
|
148
|
+
auxiliary: boolean;
|
|
149
|
+
miss: CacheMissKind;
|
|
150
|
+
project: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function parseSessionFile(path: string): Promise<{ records: ParsedRecord[]; cwd?: string }> {
|
|
154
|
+
const content = await readFile(path, "utf8");
|
|
155
|
+
const records: ParsedRecord[] = [];
|
|
156
|
+
let sourceId = path;
|
|
157
|
+
let cwd: string | undefined;
|
|
158
|
+
// Adjacency state for cache-miss detection (per file, file order).
|
|
159
|
+
let compactionPending = false;
|
|
160
|
+
let prevAssistant: { ctx: number; model: string; timestamp: number } | undefined;
|
|
161
|
+
for (const line of content.split("\n")) {
|
|
162
|
+
// Every counted entry carries a usage object; session headers only
|
|
163
|
+
// provide the source id used for cross-file fork deduplication.
|
|
164
|
+
if (!line.includes('"usage"') && !line.includes('"session"')) continue;
|
|
165
|
+
let entry: unknown;
|
|
166
|
+
try {
|
|
167
|
+
entry = JSON.parse(line);
|
|
168
|
+
} catch {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (!isRecord(entry)) continue;
|
|
172
|
+
if (entry.type === "session") {
|
|
173
|
+
if (typeof entry.id === "string" && entry.id.trim() !== "") sourceId = entry.id;
|
|
174
|
+
if (typeof entry.cwd === "string" && entry.cwd.trim() !== "") cwd = entry.cwd;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
let usageTuple: UsageTuple | undefined;
|
|
178
|
+
let provider: string;
|
|
179
|
+
let model: string;
|
|
180
|
+
let timestamp: number;
|
|
181
|
+
let reasoning = 0;
|
|
182
|
+
let auxiliary: boolean;
|
|
183
|
+
let miss: CacheMissKind = 0;
|
|
184
|
+
if (entry.type === "compaction" || entry.type === "branch_summary") {
|
|
185
|
+
usageTuple = readUsage(entry.usage);
|
|
186
|
+
provider = AUXILIARY_PROVIDER;
|
|
187
|
+
model = AUXILIARY_MODEL;
|
|
188
|
+
timestamp = toNumber(entry.timestamp) ?? 0;
|
|
189
|
+
auxiliary = true;
|
|
190
|
+
// The next assistant message starts a fresh context; never a miss.
|
|
191
|
+
compactionPending = true;
|
|
192
|
+
prevAssistant = undefined;
|
|
193
|
+
} else if (isRecord(entry.message)) {
|
|
194
|
+
const message = entry.message;
|
|
195
|
+
timestamp = toNumber(message.timestamp) ?? toNumber(entry.timestamp) ?? 0;
|
|
196
|
+
if (message.role === "assistant") {
|
|
197
|
+
provider = typeof message.provider === "string" ? message.provider : "";
|
|
198
|
+
model = typeof message.model === "string" ? message.model : "";
|
|
199
|
+
if (!provider || !model || EXCLUDED_PROVIDERS.has(provider)) continue;
|
|
200
|
+
usageTuple = readUsage(message.usage);
|
|
201
|
+
auxiliary = false;
|
|
202
|
+
const prev = prevAssistant;
|
|
203
|
+
const afterCompaction = compactionPending;
|
|
204
|
+
compactionPending = false;
|
|
205
|
+
if (usageTuple && prev && !afterCompaction) {
|
|
206
|
+
const prevCtx = prev.ctx;
|
|
207
|
+
if (prevCtx >= 20_000 && usageTuple.cacheRead < Math.min(5_000, 0.3 * prevCtx)) {
|
|
208
|
+
// Gap > 5 min: cache TTL expired; model change: different cache
|
|
209
|
+
// namespace; otherwise the cache was dropped mid-session.
|
|
210
|
+
miss = timestamp - prev.timestamp > 5 * 60_000 ? 1 : prev.model !== model ? 2 : 3;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (usageTuple) {
|
|
214
|
+
prevAssistant = {
|
|
215
|
+
ctx: usageTuple.input + usageTuple.cacheRead + usageTuple.cacheWrite,
|
|
216
|
+
model,
|
|
217
|
+
timestamp,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
} else if (message.role === "toolResult") {
|
|
221
|
+
usageTuple = readUsage(message.usage);
|
|
222
|
+
provider = AUXILIARY_PROVIDER;
|
|
223
|
+
model = AUXILIARY_MODEL;
|
|
224
|
+
auxiliary = true;
|
|
225
|
+
} else {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
reasoning = usageTuple ? toNumber((message.usage as Record<string, unknown> | undefined)?.reasoning) ?? 0 : 0;
|
|
229
|
+
} else {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (!usageTuple || timestamp <= 0) continue;
|
|
233
|
+
records.push({
|
|
234
|
+
sourceId,
|
|
235
|
+
provider,
|
|
236
|
+
model,
|
|
237
|
+
timestamp,
|
|
238
|
+
cost: usageTuple.cost,
|
|
239
|
+
input: usageTuple.input,
|
|
240
|
+
output: usageTuple.output,
|
|
241
|
+
cacheRead: usageTuple.cacheRead,
|
|
242
|
+
cacheWrite: usageTuple.cacheWrite,
|
|
243
|
+
reasoning,
|
|
244
|
+
auxiliary,
|
|
245
|
+
miss,
|
|
246
|
+
project: "unknown",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return { records, cwd };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function collectSessionFiles(dir: string): Promise<string[]> {
|
|
253
|
+
const files: string[] = [];
|
|
254
|
+
async function walk(current: string): Promise<void> {
|
|
255
|
+
let entries;
|
|
256
|
+
try {
|
|
257
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
258
|
+
} catch {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
for (const entry of entries) {
|
|
262
|
+
const fullPath = join(current, entry.name);
|
|
263
|
+
if (entry.isDirectory()) await walk(fullPath);
|
|
264
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(fullPath);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
await walk(dir);
|
|
268
|
+
return files.sort();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
interface CacheFileEntry {
|
|
272
|
+
size: number;
|
|
273
|
+
mtimeMs: number;
|
|
274
|
+
cwd?: string;
|
|
275
|
+
records: ParsedRecord[];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Serialize a record into the cached tuple form (must match sanitizeRecords). */
|
|
279
|
+
function toTuple(record: ParsedRecord): unknown[] {
|
|
280
|
+
return [
|
|
281
|
+
record.sourceId,
|
|
282
|
+
record.provider,
|
|
283
|
+
record.model,
|
|
284
|
+
record.timestamp,
|
|
285
|
+
record.cost,
|
|
286
|
+
record.input,
|
|
287
|
+
record.output,
|
|
288
|
+
record.cacheRead,
|
|
289
|
+
record.cacheWrite,
|
|
290
|
+
record.reasoning,
|
|
291
|
+
record.auxiliary,
|
|
292
|
+
record.miss,
|
|
293
|
+
];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
interface TrendsCache {
|
|
297
|
+
version: number;
|
|
298
|
+
files: Record<string, CacheFileEntry>;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function isCachedEntryFresh(entry: unknown, size: number, mtimeMs: number): boolean {
|
|
302
|
+
if (!isRecord(entry)) return false;
|
|
303
|
+
return entry.size === size && entry.mtimeMs === mtimeMs && Array.isArray(entry.records);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function sanitizeRecords(records: unknown[]): ParsedRecord[] {
|
|
307
|
+
const out: ParsedRecord[] = [];
|
|
308
|
+
for (const record of records) {
|
|
309
|
+
if (!Array.isArray(record) || record.length !== 12) return [];
|
|
310
|
+
const [sourceId, provider, model, timestamp, cost, input, output, cacheRead, cacheWrite, reasoning, auxiliary, miss] = record as [
|
|
311
|
+
unknown,
|
|
312
|
+
unknown,
|
|
313
|
+
unknown,
|
|
314
|
+
unknown,
|
|
315
|
+
unknown,
|
|
316
|
+
unknown,
|
|
317
|
+
unknown,
|
|
318
|
+
unknown,
|
|
319
|
+
unknown,
|
|
320
|
+
unknown,
|
|
321
|
+
unknown,
|
|
322
|
+
unknown,
|
|
323
|
+
];
|
|
324
|
+
if (
|
|
325
|
+
typeof sourceId !== "string" ||
|
|
326
|
+
typeof provider !== "string" ||
|
|
327
|
+
typeof model !== "string" ||
|
|
328
|
+
typeof timestamp !== "number" ||
|
|
329
|
+
typeof auxiliary !== "boolean" ||
|
|
330
|
+
[toNumber(cost), toNumber(input), toNumber(output), toNumber(cacheRead), toNumber(cacheWrite), toNumber(reasoning), toNumber(miss)].some(
|
|
331
|
+
(value) => value === undefined,
|
|
332
|
+
)
|
|
333
|
+
) {
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
out.push({
|
|
337
|
+
sourceId,
|
|
338
|
+
provider,
|
|
339
|
+
model,
|
|
340
|
+
timestamp,
|
|
341
|
+
cost: cost as number,
|
|
342
|
+
input: input as number,
|
|
343
|
+
output: output as number,
|
|
344
|
+
cacheRead: cacheRead as number,
|
|
345
|
+
cacheWrite: cacheWrite as number,
|
|
346
|
+
reasoning: reasoning as number,
|
|
347
|
+
auxiliary,
|
|
348
|
+
miss: miss as CacheMissKind,
|
|
349
|
+
project: "unknown",
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function loadCache(cachePath: string): Promise<TrendsCache> {
|
|
356
|
+
try {
|
|
357
|
+
const parsed: unknown = JSON.parse(await readFile(cachePath, "utf8"));
|
|
358
|
+
if (!isRecord(parsed) || parsed.version !== CACHE_VERSION || !isRecord(parsed.files)) {
|
|
359
|
+
return { version: CACHE_VERSION, files: {} };
|
|
360
|
+
}
|
|
361
|
+
const files: Record<string, CacheFileEntry> = {};
|
|
362
|
+
for (const [path, entry] of Object.entries(parsed.files)) {
|
|
363
|
+
if (!isRecord(entry) || typeof entry.size !== "number" || typeof entry.mtimeMs !== "number" || !Array.isArray(entry.records)) {
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const records = sanitizeRecords(entry.records).map((record) => ({ ...record, project: projectLabel(typeof entry.cwd === 'string' ? entry.cwd : '') }));
|
|
367
|
+
if (records.length !== entry.records.length) continue;
|
|
368
|
+
files[path] = { size: entry.size, mtimeMs: entry.mtimeMs, records };
|
|
369
|
+
}
|
|
370
|
+
return { version: CACHE_VERSION, files };
|
|
371
|
+
} catch {
|
|
372
|
+
return { version: CACHE_VERSION, files: {} };
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function saveCache(
|
|
377
|
+
cachePath: string,
|
|
378
|
+
cache: { version: number; files: Record<string, { size: number; mtimeMs: number; records: unknown[] }> },
|
|
379
|
+
): Promise<void> {
|
|
380
|
+
const payload = JSON.stringify(cache);
|
|
381
|
+
const tempPath = `${cachePath}.${process.pid}-${Date.now()}.tmp`;
|
|
382
|
+
try {
|
|
383
|
+
await writeFile(tempPath, payload, "utf8");
|
|
384
|
+
await rename(tempPath, cachePath);
|
|
385
|
+
} catch {
|
|
386
|
+
// Cache writing is best-effort; aggregation works without it.
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Collect trends for all sessions under `<agentDir>/sessions`. */
|
|
391
|
+
export async function collectTrends(
|
|
392
|
+
agentDir: string,
|
|
393
|
+
options?: { signal?: AbortSignal; cache?: boolean },
|
|
394
|
+
): Promise<TrendsData> {
|
|
395
|
+
const sessionsDir = join(agentDir, "sessions");
|
|
396
|
+
const cachePath = join(agentDir, "usage-trends-cache.json");
|
|
397
|
+
const useCache = options?.cache !== false;
|
|
398
|
+
const cache = useCache ? await loadCache(cachePath) : { version: CACHE_VERSION, files: {} };
|
|
399
|
+
|
|
400
|
+
const files = await collectSessionFiles(sessionsDir);
|
|
401
|
+
const allRecords: ParsedRecord[] = [];
|
|
402
|
+
let cacheDirty = false;
|
|
403
|
+
const nextFiles: Record<string, CacheFileEntry> = {};
|
|
404
|
+
for (const path of files) {
|
|
405
|
+
options?.signal?.throwIfAborted();
|
|
406
|
+
let size = 0;
|
|
407
|
+
let mtimeMs = 0;
|
|
408
|
+
try {
|
|
409
|
+
const stats = await stat(path);
|
|
410
|
+
size = stats.size;
|
|
411
|
+
mtimeMs = stats.mtimeMs;
|
|
412
|
+
} catch {
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const cached = cache.files[path];
|
|
416
|
+
if (cached && isCachedEntryFresh(cached, size, mtimeMs)) {
|
|
417
|
+
nextFiles[path] = cached;
|
|
418
|
+
allRecords.push(...cached.records);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
let parsed: { records: ParsedRecord[]; cwd?: string } = { records: [] };
|
|
422
|
+
try {
|
|
423
|
+
parsed = await parseSessionFile(path);
|
|
424
|
+
} catch {
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const project = parsed.cwd ? projectLabel(parsed.cwd) : "unknown";
|
|
428
|
+
for (const record of parsed.records) {
|
|
429
|
+
record.project = project;
|
|
430
|
+
}
|
|
431
|
+
nextFiles[path] = { size, mtimeMs, cwd: parsed.cwd, records: parsed.records };
|
|
432
|
+
allRecords.push(...parsed.records);
|
|
433
|
+
cacheDirty = true;
|
|
434
|
+
}
|
|
435
|
+
// Drop cache entries for files that disappeared.
|
|
436
|
+
for (const path of Object.keys(cache.files)) {
|
|
437
|
+
if (!(path in nextFiles)) cacheDirty = true;
|
|
438
|
+
}
|
|
439
|
+
if (useCache && cacheDirty) {
|
|
440
|
+
options?.signal?.throwIfAborted();
|
|
441
|
+
const serializable = Object.fromEntries(
|
|
442
|
+
Object.entries(nextFiles).map(([path, entry]) => [
|
|
443
|
+
path,
|
|
444
|
+
{ size: entry.size, mtimeMs: entry.mtimeMs, cwd: entry.cwd, records: entry.records.map(toTuple) },
|
|
445
|
+
]),
|
|
446
|
+
);
|
|
447
|
+
await saveCache(cachePath, { version: CACHE_VERSION, files: serializable });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Cross-file deduplication for forked session copies.
|
|
451
|
+
const seen = new Set<string>();
|
|
452
|
+
const hourly = new Map<number, Map<string, TrendCell>>();
|
|
453
|
+
const keys = new Map<string, { provider: string; model: string }>();
|
|
454
|
+
const sessions = new Map<string, Set<string>>();
|
|
455
|
+
const totalSessions = new Set<string>();
|
|
456
|
+
const sessionCost = new Map<string, number>();
|
|
457
|
+
const hourlyProject = new Map<number, Map<string, TrendCell>>();
|
|
458
|
+
const projectKeys = new Map<string, { project: string; provider: string; model: string }>();
|
|
459
|
+
const projectSessions = new Map<string, Set<string>>();
|
|
460
|
+
for (const record of allRecords) {
|
|
461
|
+
const fingerprintKey = record.auxiliary
|
|
462
|
+
? `aux:${record.sourceId}:${record.timestamp}:${fingerprint(record)}`
|
|
463
|
+
: `${record.sourceId}:${record.timestamp}:${fingerprint(record)}`;
|
|
464
|
+
if (seen.has(fingerprintKey)) continue;
|
|
465
|
+
seen.add(fingerprintKey);
|
|
466
|
+
|
|
467
|
+
const provider = record.auxiliary ? AUXILIARY_PROVIDER : record.provider;
|
|
468
|
+
const model = record.auxiliary ? AUXILIARY_MODEL : record.model;
|
|
469
|
+
const key = `${provider}${KEY_SEP}${model}`;
|
|
470
|
+
const hourStart = Math.floor(record.timestamp / HOUR_MS) * HOUR_MS;
|
|
471
|
+
let bucket = hourly.get(hourStart);
|
|
472
|
+
if (!bucket) {
|
|
473
|
+
bucket = new Map();
|
|
474
|
+
hourly.set(hourStart, bucket);
|
|
475
|
+
}
|
|
476
|
+
let cell = bucket.get(key);
|
|
477
|
+
if (!cell) {
|
|
478
|
+
cell = { messages: 0, cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, missCount: 0, missCost: 0 };
|
|
479
|
+
bucket.set(key, cell);
|
|
480
|
+
keys.set(key, { provider, model });
|
|
481
|
+
}
|
|
482
|
+
if (!record.auxiliary) cell.messages += 1;
|
|
483
|
+
cell.cost += record.cost;
|
|
484
|
+
cell.input += record.input;
|
|
485
|
+
cell.output += record.output;
|
|
486
|
+
cell.cacheRead += record.cacheRead;
|
|
487
|
+
cell.cacheWrite += record.cacheWrite;
|
|
488
|
+
if (!record.auxiliary) cell.reasoning += record.reasoning;
|
|
489
|
+
if (record.miss !== 0) {
|
|
490
|
+
cell.missCount += 1;
|
|
491
|
+
cell.missCost += record.cost;
|
|
492
|
+
}
|
|
493
|
+
if (!record.auxiliary) {
|
|
494
|
+
let sessionSet = sessions.get(key);
|
|
495
|
+
if (!sessionSet) {
|
|
496
|
+
sessionSet = new Set();
|
|
497
|
+
sessions.set(key, sessionSet);
|
|
498
|
+
}
|
|
499
|
+
sessionSet.add(record.sourceId);
|
|
500
|
+
totalSessions.add(record.sourceId);
|
|
501
|
+
sessionCost.set(record.sourceId, (sessionCost.get(record.sourceId) ?? 0) + record.cost);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Per-project aggregation (same rules, project-qualified keys).
|
|
505
|
+
const project = record.project || "unknown";
|
|
506
|
+
const pkey = `${project}${KEY_SEP}${provider}${KEY_SEP}${model}`;
|
|
507
|
+
let projectBucket = hourlyProject.get(hourStart);
|
|
508
|
+
if (!projectBucket) {
|
|
509
|
+
projectBucket = new Map();
|
|
510
|
+
hourlyProject.set(hourStart, projectBucket);
|
|
511
|
+
}
|
|
512
|
+
let projectCell = projectBucket.get(pkey);
|
|
513
|
+
if (!projectCell) {
|
|
514
|
+
projectCell = { messages: 0, cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, missCount: 0, missCost: 0 };
|
|
515
|
+
projectBucket.set(pkey, projectCell);
|
|
516
|
+
projectKeys.set(pkey, { project, provider, model });
|
|
517
|
+
}
|
|
518
|
+
if (!record.auxiliary) projectCell.messages += 1;
|
|
519
|
+
projectCell.cost += record.cost;
|
|
520
|
+
projectCell.input += record.input;
|
|
521
|
+
projectCell.output += record.output;
|
|
522
|
+
projectCell.cacheRead += record.cacheRead;
|
|
523
|
+
projectCell.cacheWrite += record.cacheWrite;
|
|
524
|
+
if (!record.auxiliary) {
|
|
525
|
+
projectCell.reasoning += record.reasoning;
|
|
526
|
+
let projectSessionSet = projectSessions.get(pkey);
|
|
527
|
+
if (!projectSessionSet) {
|
|
528
|
+
projectSessionSet = new Set();
|
|
529
|
+
projectSessions.set(pkey, projectSessionSet);
|
|
530
|
+
}
|
|
531
|
+
projectSessionSet.add(record.sourceId);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return {
|
|
535
|
+
hourly,
|
|
536
|
+
keys,
|
|
537
|
+
sessions,
|
|
538
|
+
totalSessions,
|
|
539
|
+
sessionCost,
|
|
540
|
+
hourlyProject,
|
|
541
|
+
projectKeys,
|
|
542
|
+
projectSessions,
|
|
543
|
+
generatedAt: Date.now(),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Inclusive lower bound (epoch ms) of a period; undefined = all time. */
|
|
548
|
+
export function periodStart(period: TrendPeriod, now = Date.now()): number | undefined {
|
|
549
|
+
const dayMs = 86_400_000;
|
|
550
|
+
if (period === "all") return undefined;
|
|
551
|
+
if (period === "7d") return now - 7 * dayMs;
|
|
552
|
+
if (period === "30d") return now - 30 * dayMs;
|
|
553
|
+
return now - 90 * dayMs;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function cellTokens(cell: TrendCell): number {
|
|
557
|
+
return cell.input + cell.output + cell.cacheWrite;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function cellMetric(cell: TrendCell, metric: TrendMetric): number {
|
|
561
|
+
return metric === "cost" ? cell.cost : cellTokens(cell);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
interface FlatRow {
|
|
565
|
+
hourStart: number;
|
|
566
|
+
key: string;
|
|
567
|
+
cell: TrendCell;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const flattenCache = new WeakMap<TrendsData, FlatRow[]>();
|
|
571
|
+
|
|
572
|
+
/** Flatten hourly buckets into a time-sorted array; memoized per TrendsData. */
|
|
573
|
+
function flattenHourly(data: TrendsData, fromMs?: number): FlatRow[] {
|
|
574
|
+
let all = flattenCache.get(data);
|
|
575
|
+
if (!all) {
|
|
576
|
+
all = [];
|
|
577
|
+
for (const [hourStart, bucket] of data.hourly) {
|
|
578
|
+
for (const [key, cell] of bucket) {
|
|
579
|
+
all.push({ hourStart, key, cell });
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
all.sort((a, b) => a.hourStart - b.hourStart);
|
|
583
|
+
flattenCache.set(data, all);
|
|
584
|
+
}
|
|
585
|
+
if (fromMs === undefined) return all;
|
|
586
|
+
let low = 0;
|
|
587
|
+
while (low < all.length && all[low]!.hourStart < fromMs) low += 1;
|
|
588
|
+
return all.slice(low);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const projectFlattenCache = new WeakMap<TrendsData, { hourStart: number; key: string; cell: TrendCell }[]>();
|
|
592
|
+
|
|
593
|
+
function flattenHourlyProject(data: TrendsData, fromMs?: number): { hourStart: number; key: string; cell: TrendCell }[] {
|
|
594
|
+
let all = projectFlattenCache.get(data);
|
|
595
|
+
if (!all) {
|
|
596
|
+
all = [];
|
|
597
|
+
for (const [hourStart, bucket] of data.hourlyProject) {
|
|
598
|
+
for (const [key, cell] of bucket) {
|
|
599
|
+
all.push({ hourStart, key, cell });
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
all.sort((a, b) => a.hourStart - b.hourStart);
|
|
603
|
+
projectFlattenCache.set(data, all);
|
|
604
|
+
}
|
|
605
|
+
if (fromMs === undefined) return all;
|
|
606
|
+
let low = 0;
|
|
607
|
+
while (low < all.length && all[low]!.hourStart < fromMs) low += 1;
|
|
608
|
+
return all.slice(low);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Model/provider distribution rows sorted by cost then tokens, descending. */
|
|
612
|
+
export interface DistributionRow {
|
|
613
|
+
provider: string;
|
|
614
|
+
model: string;
|
|
615
|
+
sessions: number;
|
|
616
|
+
messages: number;
|
|
617
|
+
cost: number;
|
|
618
|
+
tokens: number;
|
|
619
|
+
input: number;
|
|
620
|
+
output: number;
|
|
621
|
+
cacheRead: number;
|
|
622
|
+
cacheWrite: number;
|
|
623
|
+
reasoning: number;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export function distributionRows(data: TrendsData, fromMs: number | undefined): DistributionRow[] {
|
|
627
|
+
const rows = new Map<string, DistributionRow>();
|
|
628
|
+
for (const { key, cell } of flattenHourly(data, fromMs)) {
|
|
629
|
+
let row = rows.get(key);
|
|
630
|
+
if (!row) {
|
|
631
|
+
const names = data.keys.get(key) ?? { provider: "unknown", model: "unknown" };
|
|
632
|
+
row = { provider: names.provider, model: names.model, sessions: 0, messages: 0, cost: 0, tokens: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 };
|
|
633
|
+
rows.set(key, row);
|
|
634
|
+
}
|
|
635
|
+
row.messages += cell.messages;
|
|
636
|
+
row.cost += cell.cost;
|
|
637
|
+
row.input += cell.input;
|
|
638
|
+
row.output += cell.output;
|
|
639
|
+
row.cacheRead += cell.cacheRead;
|
|
640
|
+
row.cacheWrite += cell.cacheWrite;
|
|
641
|
+
row.reasoning += cell.reasoning;
|
|
642
|
+
row.tokens += cellTokens(cell);
|
|
643
|
+
}
|
|
644
|
+
const result = [...rows.values()];
|
|
645
|
+
for (const row of result) {
|
|
646
|
+
row.sessions = data.sessions.get(`${row.provider}${KEY_SEP}${row.model}`)?.size ?? 0;
|
|
647
|
+
}
|
|
648
|
+
result.sort((a, b) => b.cost - a.cost || b.tokens - a.tokens || a.provider.localeCompare(b.provider));
|
|
649
|
+
return result;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** Project-level distribution rows (project -> model), sorted by cost desc. */
|
|
653
|
+
export interface ProjectDistributionRow extends DistributionRow {
|
|
654
|
+
project: string;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export function projectDistributionRows(data: TrendsData, fromMs: number | undefined): ProjectDistributionRow[] {
|
|
658
|
+
const rows = new Map<string, ProjectDistributionRow>();
|
|
659
|
+
for (const { key, cell } of flattenHourlyProject(data, fromMs)) {
|
|
660
|
+
const names = data.projectKeys.get(key) ?? { project: "unknown", provider: "unknown", model: "unknown" };
|
|
661
|
+
const rowKey = `${names.project}${KEY_SEP}${names.provider}${KEY_SEP}${names.model}`;
|
|
662
|
+
let row = rows.get(rowKey);
|
|
663
|
+
if (!row) {
|
|
664
|
+
row = {
|
|
665
|
+
provider: names.provider,
|
|
666
|
+
model: `${names.provider}/${names.model}`,
|
|
667
|
+
project: names.project,
|
|
668
|
+
sessions: data.projectSessions.get(key)?.size ?? 0,
|
|
669
|
+
messages: 0,
|
|
670
|
+
cost: 0,
|
|
671
|
+
tokens: 0,
|
|
672
|
+
input: 0,
|
|
673
|
+
output: 0,
|
|
674
|
+
cacheRead: 0,
|
|
675
|
+
cacheWrite: 0,
|
|
676
|
+
reasoning: 0,
|
|
677
|
+
};
|
|
678
|
+
rows.set(rowKey, row);
|
|
679
|
+
}
|
|
680
|
+
row.messages += cell.messages;
|
|
681
|
+
row.cost += cell.cost;
|
|
682
|
+
row.input += cell.input;
|
|
683
|
+
row.output += cell.output;
|
|
684
|
+
row.cacheRead += cell.cacheRead;
|
|
685
|
+
row.cacheWrite += cell.cacheWrite;
|
|
686
|
+
row.reasoning += cell.reasoning;
|
|
687
|
+
row.tokens += cellTokens(cell);
|
|
688
|
+
}
|
|
689
|
+
const result = [...rows.values()];
|
|
690
|
+
result.sort((a, b) => b.cost - a.cost || b.tokens - a.tokens || a.project.localeCompare(b.project));
|
|
691
|
+
return result;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Time series grouped by provider (or model) at hour or day resolution.
|
|
696
|
+
* Bucket size: hourly when the span is ≤ 8 days, otherwise daily.
|
|
697
|
+
*/
|
|
698
|
+
export interface ChartSeries {
|
|
699
|
+
label: string;
|
|
700
|
+
points: { bucketStart: number; value: number }[];
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
export function chartSeries(
|
|
704
|
+
data: TrendsData,
|
|
705
|
+
options: { fromMs?: number; toMs?: number; metric: TrendMetric; groupBy: "provider" | "model" | "total" },
|
|
706
|
+
): { bucketMs: number; startMs: number; bucketCount: number; series: ChartSeries[] } {
|
|
707
|
+
const now = options.toMs ?? Date.now();
|
|
708
|
+
let fromMs = options.fromMs;
|
|
709
|
+
if (fromMs === undefined) {
|
|
710
|
+
fromMs = now - HOUR_MS;
|
|
711
|
+
for (const hourStart of data.hourly.keys()) {
|
|
712
|
+
if (hourStart < fromMs) fromMs = hourStart;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
const spanMs = Math.max(HOUR_MS, now - fromMs);
|
|
716
|
+
const bucketMs = spanMs <= 8 * 86_400_000 ? HOUR_MS : 86_400_000;
|
|
717
|
+
// Day buckets align to local midnight so chart, heatmap and sparkline agree.
|
|
718
|
+
const startMs = bucketMs === 86_400_000 ? dayStart(fromMs) : Math.floor(fromMs / bucketMs) * bucketMs;
|
|
719
|
+
const bucketCount = Math.max(1, Math.ceil((now - startMs) / bucketMs));
|
|
720
|
+
|
|
721
|
+
const flat = flattenHourly(data, fromMs);
|
|
722
|
+
const namesByKey = new Map<string, { provider: string; model: string }>();
|
|
723
|
+
const modelCounts = new Map<string, number>();
|
|
724
|
+
for (const { key } of flat) {
|
|
725
|
+
const names = data.keys.get(key) ?? { provider: "unknown", model: "unknown" };
|
|
726
|
+
namesByKey.set(key, names);
|
|
727
|
+
if (options.groupBy === "model") modelCounts.set(names.model, (modelCounts.get(names.model) ?? 0) + 1);
|
|
728
|
+
}
|
|
729
|
+
const groupLabel = (names: { provider: string; model: string }): string => {
|
|
730
|
+
if (options.groupBy === "total") return "total";
|
|
731
|
+
if (options.groupBy === "provider") return names.provider;
|
|
732
|
+
return (modelCounts.get(names.model) ?? 0) > 1 ? `${names.model} (${names.provider})` : names.model;
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
const buckets = new Map<string, number[]>();
|
|
736
|
+
const totals = new Array<number>(bucketCount).fill(0);
|
|
737
|
+
for (const { hourStart, key, cell } of flat) {
|
|
738
|
+
const names = namesByKey.get(key) ?? { provider: "unknown", model: "unknown" };
|
|
739
|
+
const groupKey = groupLabel(names);
|
|
740
|
+
const index = Math.min(bucketCount - 1, Math.floor((hourStart - startMs) / bucketMs));
|
|
741
|
+
if (index < 0) continue;
|
|
742
|
+
let series = buckets.get(groupKey);
|
|
743
|
+
if (!series) {
|
|
744
|
+
series = new Array<number>(bucketCount).fill(0);
|
|
745
|
+
buckets.set(groupKey, series);
|
|
746
|
+
}
|
|
747
|
+
const value = cellMetric(cell, options.metric);
|
|
748
|
+
series[index] = (series[index] ?? 0) + value;
|
|
749
|
+
totals[index] = (totals[index] ?? 0) + value;
|
|
750
|
+
}
|
|
751
|
+
const ranked = [...buckets.entries()]
|
|
752
|
+
.filter(([, values]) => sum(values) > 0)
|
|
753
|
+
.sort((a, b) => sum(b[1]) - sum(a[1]));
|
|
754
|
+
const series: ChartSeries[] = [];
|
|
755
|
+
if (options.groupBy !== "total") {
|
|
756
|
+
series.push({
|
|
757
|
+
label: "Total",
|
|
758
|
+
points: totals.map((value, index) => ({ bucketStart: startMs + index * bucketMs, value })),
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
ranked.slice(0, 5).forEach(([name, values]) => {
|
|
762
|
+
series.push({ label: name, points: values.map((value, index) => ({ bucketStart: startMs + index * bucketMs, value })) });
|
|
763
|
+
});
|
|
764
|
+
return { bucketMs, startMs, bucketCount, series };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function sum(values: number[]): number {
|
|
768
|
+
return values.reduce((total, value) => total + value, 0);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** Sum of every bucket in [fromMs, now] plus the distinct session count. */
|
|
772
|
+
export function periodTotals(
|
|
773
|
+
data: TrendsData,
|
|
774
|
+
fromMs: number | undefined,
|
|
775
|
+
): TrendCell & { sessions: number } {
|
|
776
|
+
const total: TrendCell = {
|
|
777
|
+
messages: 0,
|
|
778
|
+
cost: 0,
|
|
779
|
+
input: 0,
|
|
780
|
+
output: 0,
|
|
781
|
+
cacheRead: 0,
|
|
782
|
+
cacheWrite: 0,
|
|
783
|
+
reasoning: 0,
|
|
784
|
+
missCount: 0,
|
|
785
|
+
missCost: 0,
|
|
786
|
+
};
|
|
787
|
+
const sessions = new Set<string>();
|
|
788
|
+
for (const { key, cell } of flattenHourly(data, fromMs)) {
|
|
789
|
+
total.messages += cell.messages;
|
|
790
|
+
total.cost += cell.cost;
|
|
791
|
+
total.input += cell.input;
|
|
792
|
+
total.output += cell.output;
|
|
793
|
+
total.cacheRead += cell.cacheRead;
|
|
794
|
+
total.cacheWrite += cell.cacheWrite;
|
|
795
|
+
total.reasoning += cell.reasoning;
|
|
796
|
+
total.missCount += cell.missCount;
|
|
797
|
+
total.missCost += cell.missCost;
|
|
798
|
+
for (const sourceId of data.sessions.get(key) ?? []) sessions.add(sourceId);
|
|
799
|
+
}
|
|
800
|
+
return { ...total, sessions: sessions.size };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** Daily fresh-token (or cost) totals in [from, now], keyed by local day start. */
|
|
804
|
+
export function dailyTotals(
|
|
805
|
+
data: TrendsData,
|
|
806
|
+
metric: TrendMetric,
|
|
807
|
+
fromMs: number | undefined,
|
|
808
|
+
): { dayStart: number; value: number }[] {
|
|
809
|
+
const totals = new Map<number, number>();
|
|
810
|
+
for (const { hourStart, cell } of flattenHourly(data, fromMs)) {
|
|
811
|
+
const day = dayStart(hourStart);
|
|
812
|
+
totals.set(day, (totals.get(day) ?? 0) + cellMetric(cell, metric));
|
|
813
|
+
}
|
|
814
|
+
return [...totals.entries()].map(([dayStart, value]) => ({ dayStart, value })).sort((a, b) => a.dayStart - b.dayStart);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Local-midnight epoch ms for the day containing `time`. */
|
|
818
|
+
export function dayStart(time: number): number {
|
|
819
|
+
const date = new Date(time);
|
|
820
|
+
date.setHours(0, 0, 0, 0);
|
|
821
|
+
return date.getTime();
|
|
822
|
+
}
|