@firstpick/pi-extension-stats 0.1.3 → 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.
Files changed (3) hide show
  1. package/README.md +17 -1
  2. package/index.ts +462 -57
  3. 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
@@ -31,3 +31,19 @@ No required configuration.
31
31
  ## Tools
32
32
 
33
33
  None.
34
+
35
+ ## Example view
36
+
37
+ ```text
38
+ /stats 7
39
+ Token usage — last 7 days
40
+
41
+ May 06 in 18k out 4k $0.11 ████
42
+ May 07 in 42k out 9k $0.29 █████████
43
+ May 08 in 12k out 2k $0.06 ██
44
+
45
+ Total: 72k input, 15k output, $0.46
46
+ Cache hit rate: 38%
47
+ ```
48
+
49
+ Use it to understand which days, sessions, and models are driving token volume and cost.
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 type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
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
- entry = JSON.parse(line);
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, 5);
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; file: string }> {
227
- const sessions = new Map<string, { day: string; modelTokens: Map<string, number>; tokens: number; cost: number; sessionId: string; file: 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, file: r.sessionFile };
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
- file: path.basename(s.file),
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, 5);
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.map((day) => ({ day, usage: byDay.get(day) ?? emptyUsage() }));
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 activeDays = dayKeys.filter((d) => (byDay.get(d)?.total ?? 0) > 0).length;
284
- const divisor = Math.max(activeDays, dayKeys.length, 1);
285
- const avgPerDay = totals.cost / divisor;
286
- const projectedMonthly = avgPerDay * 30;
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(avgPerDay)} · projected 30d ${formatCost(projectedMonthly)} · highest ${highest && highest.cost > 0 ? `${highest.day} ${formatCost(highest.cost)}` : "n/a"}`,
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 [`Cache hit: ${hitRate.toFixed(1)}% · reads ${formatTokens(totals.cacheRead)} · writes ${formatTokens(totals.cacheWrite)}${savingsPart}`];
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 graph per day. Usage: /stats, /stats 30, /stats all",
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 parsedArgs = parseDaysArg(args);
311
- if (!parsedArgs) {
312
- ctx.ui.notify("Usage: /stats [days|all] e.g. /stats, /stats 30, /stats all", "warning");
736
+ const trimmedArgs = args.trim().toLowerCase();
737
+ if (trimmedArgs === "tokens") {
738
+ showCurrentContextTokens(ctx);
313
739
  return;
314
740
  }
315
741
 
316
- const sessionDir = ctx.sessionManager.getSessionDir();
317
- const files = listSessionFiles(sessionDir);
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 records = collectUsageRecords(files);
324
- const byDay = aggregateUsageByDay(records);
325
- const dayKeys = getScopeDayKeys(byDay, parsedArgs);
326
- const lines = buildGraphLines(byDay, dayKeys);
327
- const totals = sumUsage(byDay, dayKeys);
328
- const topModels = aggregateModelUsage(records, dayKeys);
329
- const topSessions = aggregateExpensiveSessions(records, dayKeys);
330
- const scopeLabel = parsedArgs.mode === "all" ? "all days" : `last ${parsedArgs.days} days`;
331
-
332
- const modelLines =
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${lines.join("\n")}\n\n${buildCostTrendLines(byDay, dayKeys).join("\n")}\n${buildCacheEfficiencyLines(totals).join("\n")}\n\n${modelLines.join("\n")}\n\n${sessionLines.join("\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
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-extension-stats",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Token and cost usage analytics command for Pi session history.",
5
5
  "license": "MIT",
6
6
  "keywords": [