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