@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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/index.ts +451 -63
  3. 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 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";
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 formatTokens(count: number): string {
35
- if (count < 1000) return count.toString();
36
- if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
37
- if (count < 1000000) return `${Math.round(count / 1000)}k`;
38
- if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
39
- return `${Math.round(count / 1000000)}M`;
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
- entry = JSON.parse(line);
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, 5);
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; file: string }> {
227
- const sessions = new Map<string, { day: string; modelTokens: Map<string, number>; tokens: number; cost: number; sessionId: string; file: 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, file: r.sessionFile };
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
- file: path.basename(s.file),
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, 5);
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.map((day) => ({ day, usage: byDay.get(day) ?? emptyUsage() }));
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 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;
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(avgPerDay)} · projected 30d ${formatCost(projectedMonthly)} · highest ${highest && highest.cost > 0 ? `${highest.day} ${formatCost(highest.cost)}` : "n/a"}`,
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 [`Cache hit: ${hitRate.toFixed(1)}% · reads ${formatTokens(totals.cacheRead)} · writes ${formatTokens(totals.cacheWrite)}${savingsPart}`];
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 graph per day. Usage: /stats, /stats 30, /stats all",
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 parsedArgs = parseDaysArg(args);
311
- if (!parsedArgs) {
312
- ctx.ui.notify("Usage: /stats [days|all] e.g. /stats, /stats 30, /stats all", "warning");
719
+ const trimmedArgs = args.trim().toLowerCase();
720
+ if (trimmedArgs === "tokens") {
721
+ showCurrentContextTokens(ctx);
313
722
  return;
314
723
  }
315
724
 
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
- }
725
+ const data = parseStatsCommandArgs(args, ctx);
726
+ if (!data) return;
322
727
 
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
- ];
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${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")}`,
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.4",
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
  },