@firstpick/pi-extension-stats 0.1.4 → 0.1.6
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 +1 -1
- package/index.ts +451 -63
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Token and cost analytics for Pi session history.
|
|
|
9
9
|
- Parses local Pi session `.jsonl` files for the current workspace.
|
|
10
10
|
- Aggregates usage by UTC day.
|
|
11
11
|
- Displays compact daily token bars and cost bars with totals.
|
|
12
|
-
- Shows input/output/cache breakdown, cache hit rate, estimated cache savings, cost burn rate, and top model usage.
|
|
12
|
+
- Shows input/output/cache breakdown, prompt-injection estimate (`PI: X tok`) with source split-up, cache hit rate, estimated cache savings, cost burn rate, and top model usage.
|
|
13
13
|
- Highlights highest-cost day, projected 30-day cost, most expensive sessions, and model cost efficiency.
|
|
14
14
|
|
|
15
15
|
## Install
|
package/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import
|
|
3
|
+
import { buildSessionContext, formatSkillsForPrompt } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { BuildSystemPromptOptions, ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { estimatePromptInjectionTokens, estimateTokensFromCharCount, formatTokens } from "@firstpick/pi-utils";
|
|
4
6
|
|
|
5
7
|
type DayUsage = {
|
|
6
8
|
input: number;
|
|
@@ -23,20 +25,264 @@ type UsageRecord = {
|
|
|
23
25
|
model: string;
|
|
24
26
|
sessionFile: string;
|
|
25
27
|
sessionId: string;
|
|
28
|
+
sessionName?: string;
|
|
29
|
+
sessionTitle?: string;
|
|
26
30
|
};
|
|
27
31
|
|
|
28
32
|
type Totals = DayUsage;
|
|
29
33
|
|
|
34
|
+
type PromptInjectionSource = {
|
|
35
|
+
label: string;
|
|
36
|
+
chars: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type TokenBreakdownSource = {
|
|
40
|
+
label: string;
|
|
41
|
+
chars: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
30
44
|
const DEFAULT_DAYS = 14;
|
|
31
45
|
const MAX_BAR_WIDTH = 24;
|
|
32
46
|
const COST_BAR_WIDTH = 10;
|
|
33
47
|
|
|
34
|
-
function
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
return
|
|
48
|
+
function addPromptSource(sources: PromptInjectionSource[], label: string, content: string | undefined): number {
|
|
49
|
+
if (!content) return 0;
|
|
50
|
+
const chars = content.length;
|
|
51
|
+
if (chars <= 0) return 0;
|
|
52
|
+
sources.push({ label, chars });
|
|
53
|
+
return chars;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildPromptInjectionSourcesFromPrompt(systemPrompt: string): PromptInjectionSource[] {
|
|
57
|
+
const sources: PromptInjectionSource[] = [];
|
|
58
|
+
let attributedChars = 0;
|
|
59
|
+
|
|
60
|
+
const addRange = (label: string, start: number, end: number) => {
|
|
61
|
+
if (start < 0 || end <= start) return;
|
|
62
|
+
attributedChars += addPromptSource(sources, label, systemPrompt.slice(start, end));
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const toolsStart = systemPrompt.indexOf("Available tools:\n");
|
|
66
|
+
const toolsEnd = toolsStart >= 0 ? systemPrompt.indexOf("\n\nIn addition to the tools above", toolsStart) : -1;
|
|
67
|
+
if (toolsStart >= 0 && toolsEnd > toolsStart) {
|
|
68
|
+
addRange("Tools", toolsStart, toolsEnd);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const appendStart = systemPrompt.indexOf("# APPEND_SYSTEM.md");
|
|
72
|
+
const projectContextStart = systemPrompt.indexOf("\n\n# Project Context\n");
|
|
73
|
+
const skillsStart = systemPrompt.indexOf("\n<available_skills>");
|
|
74
|
+
const dateStart = systemPrompt.indexOf("\nCurrent date:");
|
|
75
|
+
|
|
76
|
+
if (appendStart >= 0) {
|
|
77
|
+
const appendEndCandidates = [projectContextStart, skillsStart, dateStart].filter((i) => i > appendStart);
|
|
78
|
+
const appendEnd = appendEndCandidates.length > 0 ? Math.min(...appendEndCandidates) : systemPrompt.length;
|
|
79
|
+
addRange("APPEND_SYSTEM.md file", appendStart, appendEnd);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (projectContextStart >= 0) {
|
|
83
|
+
const contextEndCandidates = [skillsStart, dateStart].filter((i) => i > projectContextStart);
|
|
84
|
+
const contextEnd = contextEndCandidates.length > 0 ? Math.min(...contextEndCandidates) : systemPrompt.length;
|
|
85
|
+
const contextBlock = systemPrompt.slice(projectContextStart, contextEnd);
|
|
86
|
+
const headingRegex = /^## (.+)$/gm;
|
|
87
|
+
const headings = Array.from(contextBlock.matchAll(headingRegex));
|
|
88
|
+
|
|
89
|
+
if (headings.length === 0) {
|
|
90
|
+
attributedChars += addPromptSource(sources, "Project context files", contextBlock);
|
|
91
|
+
} else {
|
|
92
|
+
for (let i = 0; i < headings.length; i++) {
|
|
93
|
+
const heading = headings[i];
|
|
94
|
+
const nextHeading = headings[i + 1];
|
|
95
|
+
const start = heading.index;
|
|
96
|
+
const end = nextHeading?.index ?? contextBlock.length;
|
|
97
|
+
if (start === undefined) continue;
|
|
98
|
+
|
|
99
|
+
const contextPath = heading[1]?.trim() ?? "unknown";
|
|
100
|
+
const fileName = path.basename(contextPath);
|
|
101
|
+
const label = /^AGENTS\.md$/i.test(fileName)
|
|
102
|
+
? `AGENTS.md: ${contextPath}`
|
|
103
|
+
: /^CLAUDE\.md$/i.test(fileName)
|
|
104
|
+
? `CLAUDE.md: ${contextPath}`
|
|
105
|
+
: `Context file: ${contextPath}`;
|
|
106
|
+
attributedChars += addPromptSource(sources, label, contextBlock.slice(start, end));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (skillsStart >= 0) {
|
|
112
|
+
const skillsEnd = dateStart > skillsStart ? dateStart : systemPrompt.length;
|
|
113
|
+
const skillsBlock = systemPrompt.slice(skillsStart, skillsEnd);
|
|
114
|
+
const skillCount = (skillsBlock.match(/<skill>/g) ?? []).length;
|
|
115
|
+
attributedChars += addPromptSource(sources, skillCount > 0 ? `Skills (${skillCount})` : "Skills", skillsBlock);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const piPromptChars = Math.max(0, systemPrompt.length - attributedChars);
|
|
119
|
+
if (piPromptChars > 0) {
|
|
120
|
+
sources.unshift({ label: "System prompt of Pi / metadata", chars: piPromptChars });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return sources.length > 0 ? sources : [{ label: "Current system prompt", chars: systemPrompt.length }];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function buildPromptInjectionSources(systemPrompt: string, options: BuildSystemPromptOptions | null): PromptInjectionSource[] {
|
|
127
|
+
if (!options) {
|
|
128
|
+
return buildPromptInjectionSourcesFromPrompt(systemPrompt);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const sources: PromptInjectionSource[] = [];
|
|
132
|
+
let attributedChars = 0;
|
|
133
|
+
|
|
134
|
+
const addSource = (label: string, content: string | undefined) => {
|
|
135
|
+
attributedChars += addPromptSource(sources, label, content);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const selectedTools = options.selectedTools ?? ["read", "bash", "edit", "write"];
|
|
139
|
+
const visibleTools = selectedTools.filter((name) => !!options.toolSnippets?.[name]);
|
|
140
|
+
const toolsList = visibleTools.map((name) => `- ${name}: ${options.toolSnippets?.[name] ?? ""}`).join("\n");
|
|
141
|
+
addSource("Tools", toolsList);
|
|
142
|
+
|
|
143
|
+
if (options.skills && options.skills.length > 0) {
|
|
144
|
+
addSource(`Skills (${options.skills.length})`, formatSkillsForPrompt(options.skills));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (options.customPrompt) {
|
|
148
|
+
addSource("Custom system prompt", options.customPrompt);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
addSource("APPEND_SYSTEM.md / append-system", options.appendSystemPrompt);
|
|
152
|
+
|
|
153
|
+
for (const contextFile of options.contextFiles ?? []) {
|
|
154
|
+
const fileName = path.basename(contextFile.path);
|
|
155
|
+
if (/^AGENTS\.md$/i.test(fileName)) {
|
|
156
|
+
addSource(`AGENTS.md: ${contextFile.path}`, contextFile.content);
|
|
157
|
+
} else if (/^CLAUDE\.md$/i.test(fileName)) {
|
|
158
|
+
addSource(`CLAUDE.md: ${contextFile.path}`, contextFile.content);
|
|
159
|
+
} else {
|
|
160
|
+
addSource(`Context file: ${contextFile.path}`, contextFile.content);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const piPromptChars = Math.max(0, systemPrompt.length - attributedChars);
|
|
165
|
+
if (piPromptChars > 0) {
|
|
166
|
+
sources.unshift({
|
|
167
|
+
label: options.customPrompt ? "Pi prompt wrapper / metadata" : "System prompt of Pi",
|
|
168
|
+
chars: piPromptChars,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return sources;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function formatPromptInjectionLines(systemPrompt: string, options: BuildSystemPromptOptions | null): string[] {
|
|
176
|
+
const sources = buildPromptInjectionSources(systemPrompt, options)
|
|
177
|
+
.map((source) => ({ ...source, tokens: estimateTokensFromCharCount(source.chars) }))
|
|
178
|
+
.sort((a, b) => b.tokens - a.tokens || b.chars - a.chars);
|
|
179
|
+
const totalTokens = estimatePromptInjectionTokens(systemPrompt);
|
|
180
|
+
const labelWidth = Math.max("Source".length, ...sources.map((source) => source.label.length));
|
|
181
|
+
const tokenWidth = Math.max("Tokens".length, ...sources.map((source) => formatTokens(source.tokens).length));
|
|
182
|
+
const percentWidth = "%".length;
|
|
183
|
+
const separator = `├${"─".repeat(labelWidth + 2)}┼${"─".repeat(tokenWidth + 2)}┼${"─".repeat(percentWidth + 6)}┤`;
|
|
184
|
+
const rows = sources.map((source) => {
|
|
185
|
+
const percent = totalTokens > 0 ? `${((source.tokens / totalTokens) * 100).toFixed(1)}%` : "0.0%";
|
|
186
|
+
return `│ ${source.label.padEnd(labelWidth)} │ ${formatTokens(source.tokens).padStart(tokenWidth)} │ ${percent.padStart(percentWidth + 4)} │`;
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return [
|
|
190
|
+
`Prompt injection: PI: ${formatTokens(totalTokens)} tok`,
|
|
191
|
+
`┌${"─".repeat(labelWidth + 2)}┬${"─".repeat(tokenWidth + 2)}┬${"─".repeat(percentWidth + 6)}┐`,
|
|
192
|
+
`│ ${"Source".padEnd(labelWidth)} │ ${"Tokens".padStart(tokenWidth)} │ ${"%".padStart(percentWidth + 4)} │`,
|
|
193
|
+
separator,
|
|
194
|
+
...rows,
|
|
195
|
+
`└${"─".repeat(labelWidth + 2)}┴${"─".repeat(tokenWidth + 2)}┴${"─".repeat(percentWidth + 6)}┘`,
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
function stringifyContextValue(value: unknown): string {
|
|
201
|
+
if (value === undefined || value === null) return "";
|
|
202
|
+
if (typeof value === "string") return value;
|
|
203
|
+
try {
|
|
204
|
+
return JSON.stringify(value);
|
|
205
|
+
} catch {
|
|
206
|
+
return String(value);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function summarizeMessageForTokenBreakdown(message: unknown): { label: string; chars: number } {
|
|
211
|
+
const record = (message && typeof message === "object" ? message : {}) as Record<string, unknown>;
|
|
212
|
+
const role = typeof record.role === "string" ? record.role : "message";
|
|
213
|
+
const contentChars = stringifyContextValue(record.content).length;
|
|
214
|
+
const toolCallsChars = stringifyContextValue(record.toolCalls ?? record.tool_calls).length;
|
|
215
|
+
const nameChars = stringifyContextValue(record.name).length;
|
|
216
|
+
const metadataChars = Math.max(0, stringifyContextValue(record).length - contentChars - toolCallsChars - nameChars);
|
|
217
|
+
|
|
218
|
+
if (role === "assistant" && toolCallsChars > 2) {
|
|
219
|
+
return { label: "Assistant messages + tool calls", chars: contentChars + toolCallsChars + metadataChars };
|
|
220
|
+
}
|
|
221
|
+
if (role === "tool" || role === "function") {
|
|
222
|
+
return { label: "Tool results / command output", chars: stringifyContextValue(record).length };
|
|
223
|
+
}
|
|
224
|
+
if (role === "user") {
|
|
225
|
+
return { label: "User messages / working context", chars: stringifyContextValue(record).length };
|
|
226
|
+
}
|
|
227
|
+
if (role === "assistant") {
|
|
228
|
+
return { label: "Assistant messages", chars: stringifyContextValue(record).length };
|
|
229
|
+
}
|
|
230
|
+
return { label: "Other session context", chars: stringifyContextValue(record).length };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function buildCurrentContextTokenSources(systemPrompt: string, options: BuildSystemPromptOptions | null, ctx: { sessionManager: { getBranch(): unknown[]; getLeafId(): string | null } }): TokenBreakdownSource[] {
|
|
234
|
+
const sources = new Map<string, TokenBreakdownSource>();
|
|
235
|
+
const add = (label: string, chars: number) => {
|
|
236
|
+
if (chars <= 0) return;
|
|
237
|
+
const prev = sources.get(label);
|
|
238
|
+
if (prev) {
|
|
239
|
+
prev.chars += chars;
|
|
240
|
+
} else {
|
|
241
|
+
sources.set(label, { label, chars });
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
for (const source of buildPromptInjectionSources(systemPrompt, options)) {
|
|
246
|
+
add(source.label, source.chars);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const branch = ctx.sessionManager.getBranch() as never[];
|
|
251
|
+
const sessionContext = buildSessionContext(branch, ctx.sessionManager.getLeafId());
|
|
252
|
+
for (const message of sessionContext.messages) {
|
|
253
|
+
const summary = summarizeMessageForTokenBreakdown(message);
|
|
254
|
+
add(summary.label, summary.chars);
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
// Keep /stats tokens useful even if the session branch cannot be reconstructed.
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return Array.from(sources.values());
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function formatTokenBreakdownTable(title: string, sources: TokenBreakdownSource[], actualTotalTokens?: number | null): string[] {
|
|
264
|
+
const rows = sources
|
|
265
|
+
.map((source) => ({ ...source, tokens: estimateTokensFromCharCount(source.chars) }))
|
|
266
|
+
.sort((a, b) => b.tokens - a.tokens || b.chars - a.chars);
|
|
267
|
+
const estimatedTotalTokens = rows.reduce((sum, row) => sum + row.tokens, 0);
|
|
268
|
+
const percentBase = actualTotalTokens && actualTotalTokens > 0 ? actualTotalTokens : estimatedTotalTokens;
|
|
269
|
+
const labelWidth = Math.max("Source".length, ...rows.map((row) => row.label.length));
|
|
270
|
+
const tokenWidth = Math.max("Tokens".length, ...rows.map((row) => formatTokens(row.tokens).length));
|
|
271
|
+
const percentWidth = "%".length;
|
|
272
|
+
const separator = `├${"─".repeat(labelWidth + 2)}┼${"─".repeat(tokenWidth + 2)}┼${"─".repeat(percentWidth + 6)}┤`;
|
|
273
|
+
const totalLabel = actualTotalTokens && actualTotalTokens > 0 ? `${formatTokens(actualTotalTokens)} tok actual · ~${formatTokens(estimatedTotalTokens)} estimated` : `~${formatTokens(estimatedTotalTokens)} tok estimated`;
|
|
274
|
+
|
|
275
|
+
return [
|
|
276
|
+
`${title}: ${totalLabel}`,
|
|
277
|
+
`┌${"─".repeat(labelWidth + 2)}┬${"─".repeat(tokenWidth + 2)}┬${"─".repeat(percentWidth + 6)}┐`,
|
|
278
|
+
`│ ${"Source".padEnd(labelWidth)} │ ${"Tokens".padStart(tokenWidth)} │ ${"%".padStart(percentWidth + 4)} │`,
|
|
279
|
+
separator,
|
|
280
|
+
...rows.map((row) => {
|
|
281
|
+
const percent = percentBase > 0 ? `${((row.tokens / percentBase) * 100).toFixed(1)}%` : "0.0%";
|
|
282
|
+
return `│ ${row.label.padEnd(labelWidth)} │ ${formatTokens(row.tokens).padStart(tokenWidth)} │ ${percent.padStart(percentWidth + 4)} │`;
|
|
283
|
+
}),
|
|
284
|
+
`└${"─".repeat(labelWidth + 2)}┴${"─".repeat(tokenWidth + 2)}┴${"─".repeat(percentWidth + 6)}┘`,
|
|
285
|
+
];
|
|
40
286
|
}
|
|
41
287
|
|
|
42
288
|
function formatCost(cost: number): string {
|
|
@@ -77,6 +323,31 @@ function listSessionFiles(sessionDir: string): string[] {
|
|
|
77
323
|
}
|
|
78
324
|
}
|
|
79
325
|
|
|
326
|
+
function extractMessageText(content: unknown): string {
|
|
327
|
+
if (typeof content === "string") return content;
|
|
328
|
+
if (Array.isArray(content)) {
|
|
329
|
+
return content
|
|
330
|
+
.map((part) => {
|
|
331
|
+
if (typeof part === "string") return part;
|
|
332
|
+
if (!part || typeof part !== "object") return "";
|
|
333
|
+
const record = part as Record<string, unknown>;
|
|
334
|
+
if (typeof record.text === "string") return record.text;
|
|
335
|
+
if (typeof record.content === "string") return record.content;
|
|
336
|
+
return "";
|
|
337
|
+
})
|
|
338
|
+
.filter(Boolean)
|
|
339
|
+
.join(" ");
|
|
340
|
+
}
|
|
341
|
+
return "";
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function formatSessionTitle(text: string, maxLength = 72): string | undefined {
|
|
345
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
346
|
+
if (!normalized) return undefined;
|
|
347
|
+
if (normalized.length <= maxLength) return normalized;
|
|
348
|
+
return `${normalized.slice(0, maxLength - 1).trimEnd()}…`;
|
|
349
|
+
}
|
|
350
|
+
|
|
80
351
|
function collectUsageRecords(sessionFiles: string[]): UsageRecord[] {
|
|
81
352
|
const records: UsageRecord[] = [];
|
|
82
353
|
|
|
@@ -89,17 +360,27 @@ function collectUsageRecords(sessionFiles: string[]): UsageRecord[] {
|
|
|
89
360
|
}
|
|
90
361
|
|
|
91
362
|
const sessionId = path.basename(file, ".jsonl");
|
|
363
|
+
const entries: any[] = [];
|
|
92
364
|
|
|
93
365
|
for (const line of content.split(/\r?\n/)) {
|
|
94
366
|
if (!line.trim()) continue;
|
|
95
367
|
|
|
96
|
-
let entry: any;
|
|
97
368
|
try {
|
|
98
|
-
|
|
369
|
+
entries.push(JSON.parse(line));
|
|
99
370
|
} catch {
|
|
100
371
|
continue;
|
|
101
372
|
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const sessionName = entries
|
|
376
|
+
.filter((entry) => entry?.type === "session_info" && typeof entry?.name === "string" && entry.name.trim())
|
|
377
|
+
.at(-1)?.name.trim();
|
|
378
|
+
const firstUserText = entries
|
|
379
|
+
.find((entry) => entry?.type === "message" && entry?.message?.role === "user")
|
|
380
|
+
?.message?.content;
|
|
381
|
+
const sessionTitle = formatSessionTitle(extractMessageText(firstUserText));
|
|
102
382
|
|
|
383
|
+
for (const entry of entries) {
|
|
103
384
|
if (entry?.type !== "message") continue;
|
|
104
385
|
if (entry?.message?.role !== "assistant") continue;
|
|
105
386
|
|
|
@@ -129,6 +410,8 @@ function collectUsageRecords(sessionFiles: string[]): UsageRecord[] {
|
|
|
129
410
|
model: `${provider}/${model}`,
|
|
130
411
|
sessionFile: file,
|
|
131
412
|
sessionId,
|
|
413
|
+
sessionName,
|
|
414
|
+
sessionTitle,
|
|
132
415
|
});
|
|
133
416
|
}
|
|
134
417
|
}
|
|
@@ -191,7 +474,7 @@ function scopedRecords(records: UsageRecord[], dayKeys: string[]): UsageRecord[]
|
|
|
191
474
|
return records.filter((r) => daySet.has(r.day));
|
|
192
475
|
}
|
|
193
476
|
|
|
194
|
-
function aggregateModelUsage(records: UsageRecord[], dayKeys: string[]): Array<{ model: string; tokens: number; percent: number; cost: number; costPercent: number; avgCostPerMillion: number; avgOutputTokens: number; messages: number }> {
|
|
477
|
+
function aggregateModelUsage(records: UsageRecord[], dayKeys: string[], limit = 10): Array<{ model: string; tokens: number; percent: number; cost: number; costPercent: number; avgCostPerMillion: number; avgOutputTokens: number; messages: number }> {
|
|
195
478
|
const scoped = scopedRecords(records, dayKeys);
|
|
196
479
|
const modelTotals = new Map<string, { tokens: number; output: number; cost: number; messages: number }>();
|
|
197
480
|
const totalTokens = scoped.reduce((acc, r) => acc + r.total, 0);
|
|
@@ -220,17 +503,19 @@ function aggregateModelUsage(records: UsageRecord[], dayKeys: string[]): Array<{
|
|
|
220
503
|
messages: v.messages,
|
|
221
504
|
}))
|
|
222
505
|
.sort((a, b) => b.cost - a.cost || b.tokens - a.tokens)
|
|
223
|
-
.slice(0,
|
|
506
|
+
.slice(0, limit);
|
|
224
507
|
}
|
|
225
508
|
|
|
226
|
-
function aggregateExpensiveSessions(records: UsageRecord[], dayKeys: string[]): Array<{ day: string; model: string; tokens: number; cost: number; sessionId: string;
|
|
227
|
-
const sessions = new Map<string, { day: string; modelTokens: Map<string, number>; tokens: number; cost: number; sessionId: string;
|
|
509
|
+
function aggregateExpensiveSessions(records: UsageRecord[], dayKeys: string[], limit = 10): Array<{ day: string; model: string; tokens: number; cost: number; sessionId: string; sessionName?: string; sessionTitle?: string; displayName: string }> {
|
|
510
|
+
const sessions = new Map<string, { day: string; modelTokens: Map<string, number>; tokens: number; cost: number; sessionId: string; sessionName?: string; sessionTitle?: string }>();
|
|
228
511
|
|
|
229
512
|
for (const r of scopedRecords(records, dayKeys)) {
|
|
230
|
-
const prev = sessions.get(r.sessionFile) ?? { day: r.day, modelTokens: new Map(), tokens: 0, cost: 0, sessionId: r.sessionId,
|
|
513
|
+
const prev = sessions.get(r.sessionFile) ?? { day: r.day, modelTokens: new Map(), tokens: 0, cost: 0, sessionId: r.sessionId, sessionName: r.sessionName, sessionTitle: r.sessionTitle };
|
|
231
514
|
if (r.day < prev.day) prev.day = r.day;
|
|
232
515
|
prev.tokens += r.total;
|
|
233
516
|
prev.cost += r.cost;
|
|
517
|
+
if (!prev.sessionName && r.sessionName) prev.sessionName = r.sessionName;
|
|
518
|
+
if (!prev.sessionTitle && r.sessionTitle) prev.sessionTitle = r.sessionTitle;
|
|
234
519
|
prev.modelTokens.set(r.model, (prev.modelTokens.get(r.model) ?? 0) + r.total);
|
|
235
520
|
sessions.set(r.sessionFile, prev);
|
|
236
521
|
}
|
|
@@ -242,22 +527,30 @@ function aggregateExpensiveSessions(records: UsageRecord[], dayKeys: string[]):
|
|
|
242
527
|
tokens: s.tokens,
|
|
243
528
|
cost: s.cost,
|
|
244
529
|
sessionId: s.sessionId,
|
|
245
|
-
|
|
530
|
+
sessionName: s.sessionName,
|
|
531
|
+
sessionTitle: s.sessionTitle,
|
|
532
|
+
displayName: s.sessionName ? `"${s.sessionName}"` : s.sessionTitle ? `"${s.sessionTitle}"` : s.sessionId,
|
|
246
533
|
}))
|
|
247
534
|
.sort((a, b) => b.cost - a.cost || b.tokens - a.tokens)
|
|
248
|
-
.slice(0,
|
|
535
|
+
.slice(0, limit);
|
|
249
536
|
}
|
|
250
537
|
|
|
251
|
-
function buildGraphLines(byDay: Map<string, DayUsage>, dayKeys: string[]): string[] {
|
|
538
|
+
function buildGraphLines(byDay: Map<string, DayUsage>, dayKeys: string[], omitZeroDays = false): string[] {
|
|
252
539
|
if (dayKeys.length === 0) {
|
|
253
540
|
return ["No usage data found yet."];
|
|
254
541
|
}
|
|
255
542
|
|
|
256
|
-
const data = dayKeys
|
|
543
|
+
const data = dayKeys
|
|
544
|
+
.map((day) => ({ day, usage: byDay.get(day) ?? emptyUsage() }))
|
|
545
|
+
.filter((d) => !omitZeroDays || d.usage.total > 0 || d.usage.cost > 0);
|
|
257
546
|
const maxTotal = Math.max(...data.map((d) => d.usage.total), 0);
|
|
258
547
|
const maxCost = Math.max(...data.map((d) => d.usage.cost), 0);
|
|
259
548
|
const lines: string[] = [];
|
|
260
549
|
|
|
550
|
+
if (data.length === 0) {
|
|
551
|
+
return ["No non-zero usage data found in selected range."];
|
|
552
|
+
}
|
|
553
|
+
|
|
261
554
|
for (const { day, usage } of data) {
|
|
262
555
|
const tokenBarLen = usage.total <= 0 || maxTotal <= 0 ? 0 : Math.max(1, Math.round((usage.total / maxTotal) * MAX_BAR_WIDTH));
|
|
263
556
|
const costBarLen = usage.cost <= 0 || maxCost <= 0 ? 0 : Math.max(1, Math.round((usage.cost / maxCost) * COST_BAR_WIDTH));
|
|
@@ -280,76 +573,171 @@ function buildGraphLines(byDay: Map<string, DayUsage>, dayKeys: string[]): strin
|
|
|
280
573
|
|
|
281
574
|
function buildCostTrendLines(byDay: Map<string, DayUsage>, dayKeys: string[]): string[] {
|
|
282
575
|
const totals = sumUsage(byDay, dayKeys);
|
|
283
|
-
const
|
|
284
|
-
const
|
|
285
|
-
const
|
|
286
|
-
const projectedMonthly =
|
|
576
|
+
const activeKeys = dayKeys.filter((d) => (byDay.get(d)?.cost ?? 0) > 0 || (byDay.get(d)?.total ?? 0) > 0);
|
|
577
|
+
const calendarAvg = totals.cost / Math.max(dayKeys.length, 1);
|
|
578
|
+
const activeAvg = totals.cost / Math.max(activeKeys.length, 1);
|
|
579
|
+
const projectedMonthly = calendarAvg * 30;
|
|
287
580
|
const highest = dayKeys
|
|
288
|
-
.map((day) => ({ day, cost: byDay.get(day)?.cost ?? 0 }))
|
|
581
|
+
.map((day) => ({ day, cost: byDay.get(day)?.cost ?? 0, tokens: byDay.get(day)?.total ?? 0 }))
|
|
289
582
|
.sort((a, b) => b.cost - a.cost)[0];
|
|
583
|
+
const lastActive = activeKeys.at(-1);
|
|
584
|
+
const lastActiveCost = lastActive ? (byDay.get(lastActive)?.cost ?? 0) : 0;
|
|
290
585
|
|
|
291
586
|
return [
|
|
292
|
-
`Cost trend: avg/day ${formatCost(
|
|
587
|
+
`Cost trend: avg/day ${formatCost(calendarAvg)} · active-day avg ${formatCost(activeAvg)} · projected 30d ${formatCost(projectedMonthly)} · highest ${highest && highest.cost > 0 ? `${highest.day} ${formatCost(highest.cost)} (${formatTokens(highest.tokens)} tok)` : "n/a"} · active days ${activeKeys.length}/${dayKeys.length}`,
|
|
588
|
+
`Latest active day: ${lastActive ? `${lastActive} ${formatCost(lastActiveCost)} · ${formatTokens(byDay.get(lastActive)?.total ?? 0)} tok` : "n/a"}`,
|
|
293
589
|
];
|
|
294
590
|
}
|
|
295
591
|
|
|
296
592
|
function buildCacheEfficiencyLines(totals: Totals): string[] {
|
|
297
593
|
const hitRate = totals.total > 0 ? (totals.cacheRead / totals.total) * 100 : 0;
|
|
298
594
|
const nonCacheTokens = totals.input + totals.output + totals.cacheWrite;
|
|
595
|
+
const inputShare = totals.total > 0 ? (totals.input / totals.total) * 100 : 0;
|
|
596
|
+
const outputShare = totals.total > 0 ? (totals.output / totals.total) * 100 : 0;
|
|
299
597
|
const avgCostPerNonCacheToken = nonCacheTokens > 0 && totals.cost > 0 ? totals.cost / nonCacheTokens : 0;
|
|
300
598
|
const estimatedSavings = totals.cacheRead * avgCostPerNonCacheToken;
|
|
301
599
|
const savingsPart = estimatedSavings > 0 ? ` · est. cache savings ${formatCost(estimatedSavings)}` : "";
|
|
302
600
|
|
|
303
|
-
return [
|
|
601
|
+
return [
|
|
602
|
+
`Cache hit: ${hitRate.toFixed(1)}% · reads ${formatTokens(totals.cacheRead)} · writes ${formatTokens(totals.cacheWrite)}${savingsPart}`,
|
|
603
|
+
`Token mix: input ${formatTokens(totals.input)} (${inputShare.toFixed(1)}%) · output ${formatTokens(totals.output)} (${outputShare.toFixed(1)}%) · non-cache ${formatTokens(nonCacheTokens)} · total ${formatTokens(totals.total)}`,
|
|
604
|
+
];
|
|
304
605
|
}
|
|
305
606
|
|
|
306
607
|
export default function statsExtension(pi: ExtensionAPI) {
|
|
608
|
+
let latestSystemPromptOptions: BuildSystemPromptOptions | null = null;
|
|
609
|
+
|
|
610
|
+
pi.on("before_agent_start", async (event) => {
|
|
611
|
+
latestSystemPromptOptions = event.systemPromptOptions;
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const showCurrentContextTokens = (ctx: ExtensionCommandContext) => {
|
|
615
|
+
const usage = ctx.getContextUsage();
|
|
616
|
+
const contextSources = buildCurrentContextTokenSources(ctx.getSystemPrompt(), latestSystemPromptOptions, ctx);
|
|
617
|
+
const contextWindow = usage?.contextWindow ? ` / ${formatTokens(usage.contextWindow)} window` : "";
|
|
618
|
+
const percent = usage?.percent !== null && usage?.percent !== undefined ? ` (${usage.percent.toFixed(1)}%)` : "";
|
|
619
|
+
ctx.ui.notify(`${formatTokenBreakdownTable("Current context", contextSources, usage?.tokens).join("\n")}\nContext usage: ${usage?.tokens ? formatTokens(usage.tokens) : "?"}${contextWindow}${percent}`, "info");
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
pi.registerCommand("stats-tokens", {
|
|
623
|
+
description: "Show current context token breakdown by source/type.",
|
|
624
|
+
handler: async (_args, ctx) => {
|
|
625
|
+
showCurrentContextTokens(ctx);
|
|
626
|
+
},
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
const loadStatsData = (ctx: ExtensionCommandContext, parsedArgs: { mode: "range"; days: number } | { mode: "all" }) => {
|
|
630
|
+
const sessionDir = ctx.sessionManager.getSessionDir();
|
|
631
|
+
const files = listSessionFiles(sessionDir);
|
|
632
|
+
const records = collectUsageRecords(files);
|
|
633
|
+
const byDay = aggregateUsageByDay(records);
|
|
634
|
+
const dayKeys = getScopeDayKeys(byDay, parsedArgs);
|
|
635
|
+
const totals = sumUsage(byDay, dayKeys);
|
|
636
|
+
const scopeLabel = parsedArgs.mode === "all" ? "all days" : `last ${parsedArgs.days} days`;
|
|
637
|
+
return { files, records, byDay, dayKeys, totals, scopeLabel };
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
const parseStatsCommandArgs = (args: string, ctx: ExtensionCommandContext) => {
|
|
641
|
+
const parsedArgs = parseDaysArg(args);
|
|
642
|
+
if (!parsedArgs) {
|
|
643
|
+
ctx.ui.notify("Usage: command [days|all] e.g. /stats-last, /stats-last 30, /stats-last all", "warning");
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
const data = loadStatsData(ctx, parsedArgs);
|
|
647
|
+
if (data.files.length === 0) {
|
|
648
|
+
ctx.ui.notify("No sessions found for this workspace yet.", "info");
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
return data;
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
const formatModelComparisonLines = (records: UsageRecord[], dayKeys: string[], totals: Totals) => {
|
|
655
|
+
const topModels = aggregateModelUsage(records, dayKeys, 20);
|
|
656
|
+
return topModels.length === 0
|
|
657
|
+
? ["Model comparison: no model usage in selected range"]
|
|
658
|
+
: [
|
|
659
|
+
"Model comparison:",
|
|
660
|
+
...topModels.map((m, i) => {
|
|
661
|
+
const costPart = totals.cost > 0 ? ` · ${m.costPercent.toFixed(1)}% spend` : "";
|
|
662
|
+
return `${i + 1}. ${m.model} — ${m.percent.toFixed(1)}% tokens (${formatTokens(m.tokens)}) · ${formatCost(m.cost)}${costPart} · ${formatCost(m.avgCostPerMillion)}/1M tok · avg ↓${formatTokens(Math.round(m.avgOutputTokens))}/msg · ${m.messages} msgs`;
|
|
663
|
+
}),
|
|
664
|
+
];
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
const formatExpensiveSessionLines = (records: UsageRecord[], dayKeys: string[]) => {
|
|
668
|
+
const topSessions = aggregateExpensiveSessions(records, dayKeys, 20);
|
|
669
|
+
return topSessions.length === 0
|
|
670
|
+
? ["Most expensive sessions: none in selected range"]
|
|
671
|
+
: [
|
|
672
|
+
"Most expensive sessions:",
|
|
673
|
+
...topSessions.map((s, i) => `${i + 1}. ${s.day} ${s.displayName} — ${formatCost(s.cost)} · ${formatTokens(s.tokens)} tok · ${s.model}`),
|
|
674
|
+
];
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
const registerScopedStatsCommand = (name: string, description: string, render: (data: ReturnType<typeof loadStatsData>, ctx: ExtensionCommandContext) => string[]) => {
|
|
678
|
+
pi.registerCommand(name, {
|
|
679
|
+
description,
|
|
680
|
+
handler: async (args, ctx) => {
|
|
681
|
+
const data = parseStatsCommandArgs(args, ctx);
|
|
682
|
+
if (!data) return;
|
|
683
|
+
ctx.ui.notify(render(data, ctx).join("\n"), "info");
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
registerScopedStatsCommand("stats-most-expense", "Show most expensive sessions. Usage: /stats-most-expense [days|all]", (data) =>
|
|
689
|
+
formatExpensiveSessionLines(data.records, data.dayKeys),
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
registerScopedStatsCommand("stats-model-compare", "Show model token/cost comparison. Usage: /stats-model-compare [days|all]", (data) =>
|
|
693
|
+
formatModelComparisonLines(data.records, data.dayKeys, data.totals),
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
registerScopedStatsCommand("stats-cost-trend", "Show cost trend and projections. Usage: /stats-cost-trend [days|all]", (data) =>
|
|
697
|
+
buildCostTrendLines(data.byDay, data.dayKeys),
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
registerScopedStatsCommand("stats-cache", "Show cache efficiency and token mix. Usage: /stats-cache [days|all]", (data) =>
|
|
701
|
+
buildCacheEfficiencyLines(data.totals),
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
registerScopedStatsCommand("stats-last", "Show non-zero daily usage graph. Usage: /stats-last [days|all]", (data, ctx) => {
|
|
705
|
+
const promptInjectionTokens = estimatePromptInjectionTokens(ctx.getSystemPrompt());
|
|
706
|
+
return [`📊 Token stats (${data.scopeLabel}, ${data.files.length} sessions) · PI: ${formatTokens(promptInjectionTokens)} tok`, "", ...buildGraphLines(data.byDay, data.dayKeys, true)];
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
pi.registerCommand("stats-pi", {
|
|
710
|
+
description: "Show prompt-injection token breakdown.",
|
|
711
|
+
handler: async (_args, ctx) => {
|
|
712
|
+
ctx.ui.notify(formatPromptInjectionLines(ctx.getSystemPrompt(), latestSystemPromptOptions).join("\n"), "info");
|
|
713
|
+
},
|
|
714
|
+
});
|
|
715
|
+
|
|
307
716
|
pi.registerCommand("stats", {
|
|
308
|
-
description: "Show token usage
|
|
717
|
+
description: "Show token usage dashboard. Usage: /stats, /stats 30, /stats all. Details: /stats-tokens, /stats-pi, /stats-last, /stats-most-expense, /stats-model-compare, /stats-cost-trend, /stats-cache",
|
|
309
718
|
handler: async (args, ctx) => {
|
|
310
|
-
const
|
|
311
|
-
if (
|
|
312
|
-
ctx
|
|
719
|
+
const trimmedArgs = args.trim().toLowerCase();
|
|
720
|
+
if (trimmedArgs === "tokens") {
|
|
721
|
+
showCurrentContextTokens(ctx);
|
|
313
722
|
return;
|
|
314
723
|
}
|
|
315
724
|
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
if (files.length === 0) {
|
|
319
|
-
ctx.ui.notify("No sessions found for this workspace yet.", "info");
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
725
|
+
const data = parseStatsCommandArgs(args, ctx);
|
|
726
|
+
if (!data) return;
|
|
322
727
|
|
|
323
|
-
const
|
|
324
|
-
const
|
|
325
|
-
const
|
|
326
|
-
const
|
|
327
|
-
const
|
|
328
|
-
const
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
topModels.length === 0
|
|
334
|
-
? ["Model comparison: no model usage in selected range"]
|
|
335
|
-
: [
|
|
336
|
-
"Model comparison:",
|
|
337
|
-
...topModels.map((m, i) => {
|
|
338
|
-
const costPart = totals.cost > 0 ? ` · ${m.costPercent.toFixed(1)}% spend` : "";
|
|
339
|
-
return `${i + 1}. ${m.model} — ${m.percent.toFixed(1)}% tokens (${formatTokens(m.tokens)}) · ${formatCost(m.cost)}${costPart} · ${formatCost(m.avgCostPerMillion)}/1M tok · avg ↓${formatTokens(Math.round(m.avgOutputTokens))}/msg`;
|
|
340
|
-
}),
|
|
341
|
-
];
|
|
342
|
-
|
|
343
|
-
const sessionLines =
|
|
344
|
-
topSessions.length === 0
|
|
345
|
-
? ["Most expensive sessions: none in selected range"]
|
|
346
|
-
: [
|
|
347
|
-
"Most expensive sessions:",
|
|
348
|
-
...topSessions.map((s, i) => `${i + 1}. ${s.day} ${s.sessionId} — ${formatCost(s.cost)} · ${formatTokens(s.tokens)} tok · ${s.model} · ${s.file}`),
|
|
349
|
-
];
|
|
728
|
+
const systemPrompt = ctx.getSystemPrompt();
|
|
729
|
+
const promptInjectionTokens = estimatePromptInjectionTokens(systemPrompt);
|
|
730
|
+
const graphLines = buildGraphLines(data.byDay, data.dayKeys, true);
|
|
731
|
+
const promptInjectionLines = formatPromptInjectionLines(systemPrompt, latestSystemPromptOptions);
|
|
732
|
+
const modelLines = formatModelComparisonLines(data.records, data.dayKeys, data.totals).slice(0, 7);
|
|
733
|
+
const sessionLines = formatExpensiveSessionLines(data.records, data.dayKeys).slice(0, 7);
|
|
734
|
+
const commandLines = [
|
|
735
|
+
"Detailed commands:",
|
|
736
|
+
"/stats-last · /stats-most-expense · /stats-model-compare · /stats-pi · /stats-cost-trend · /stats-cache · /stats-tokens",
|
|
737
|
+
];
|
|
350
738
|
|
|
351
739
|
ctx.ui.notify(
|
|
352
|
-
`📊 Token stats (${scopeLabel}, ${files.length} sessions)\n\n${
|
|
740
|
+
`📊 Token stats (${data.scopeLabel}, ${data.files.length} sessions) · PI: ${formatTokens(promptInjectionTokens)} tok\n\n${graphLines.join("\n")}\n\n${promptInjectionLines.join("\n")}\n\n${buildCostTrendLines(data.byDay, data.dayKeys).join("\n")}\n${buildCacheEfficiencyLines(data.totals).join("\n")}\n\n${modelLines.join("\n")}\n\n${sessionLines.join("\n")}\n\n${commandLines.join("\n")}`,
|
|
353
741
|
"info",
|
|
354
742
|
);
|
|
355
743
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firstpick/pi-extension-stats",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Token and cost usage analytics command for Pi session history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
"./index.ts"
|
|
15
15
|
]
|
|
16
16
|
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@firstpick/pi-utils": "^0.1.3"
|
|
19
|
+
},
|
|
17
20
|
"peerDependencies": {
|
|
18
21
|
"@earendil-works/pi-coding-agent": "*"
|
|
19
22
|
},
|