@firstpick/pi-extension-stats 0.1.6 → 0.1.8

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 +25 -4
  2. package/index.ts +164 -18
  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, prompt-injection estimate (`PI: X tok`) with source split-up, cache hit rate, estimated cache savings, cost burn rate, and top model usage.
12
+ - Shows input/output/cache breakdown, estimated initial prompt input (`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
@@ -24,9 +24,30 @@ No required configuration.
24
24
 
25
25
  ## Commands
26
26
 
27
- - `/stats` — show last 14 days.
28
- - `/stats <days>` — show last N days.
29
- - `/stats all` — show all available days.
27
+ - `/stats [days|all]` — show token usage dashboard (default: last 14 days).
28
+ - `/stats tokens` — show current context token breakdown by source/type.
29
+ - `/stats-pi` — show estimated initial prompt input token breakdown. It counts Pi's system prompt text, active provider-level tool schemas, framing overhead, and optional historical calibration.
30
+ - `/stats-last [days|all]` — show non-zero daily usage graph.
31
+ - `/stats-most-expense [days|all]` — show most expensive sessions.
32
+ - `/stats-model-compare [days|all]` — show model token/cost comparison.
33
+ - `/stats-cost-trend [days|all]` — show cost trend and projections.
34
+ - `/stats-cache [days|all]` — show cache efficiency and token mix.
35
+
36
+ ## Prompt input estimate
37
+
38
+ `/stats-pi` and the `PI: ~X tok` value in `/stats` estimate the full initial model input, not just raw prompt text. `/stats-pi` can be run before any LLM prompt in a fresh session.
39
+
40
+ The calculation is intentionally provider-agnostic:
41
+
42
+ ```text
43
+ promptTextTokens = weighted text estimate of ctx.getSystemPrompt()
44
+ toolSchemaTokens = weighted text estimate of active tool definitions JSON
45
+ framingTokens = conservative message/request framing allowance
46
+ baseEstimate = promptTextTokens + toolSchemaTokens + framingTokens
47
+ estimatedInitialInput = baseEstimate × historicalCalibrationMultiplier
48
+ ```
49
+
50
+ The historical multiplier is learned opportunistically from future sessions by comparing the pre-call estimate with the provider-reported first assistant `usage.input + usage.cacheRead + usage.cacheWrite` after subtracting the first user prompt estimate. Without samples, `/stats-pi` reports an uncalibrated estimate and a conservative range. Provider-reported usage in Pi session JSONL remains the authoritative post-call value.
30
51
 
31
52
  ## Tools
32
53
 
package/index.ts CHANGED
@@ -2,7 +2,18 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { buildSessionContext, formatSkillsForPrompt } from "@earendil-works/pi-coding-agent";
4
4
  import type { BuildSystemPromptOptions, ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
5
- import { estimatePromptInjectionTokens, estimateTokensFromCharCount, formatTokens } from "@firstpick/pi-utils";
5
+ import {
6
+ appendInitialPromptCalibrationRecord,
7
+ buildInitialPromptCalibrationRecord,
8
+ collectInitialPromptCalibration,
9
+ estimateInitialPromptInput,
10
+ estimatePromptInjectionTokens,
11
+ estimateTokensFromCharCount,
12
+ formatTokens,
13
+ type InitialPromptCalibration,
14
+ type InitialPromptInputEstimate,
15
+ type InitialPromptToolInfo,
16
+ } from "@firstpick/pi-utils";
6
17
 
7
18
  type DayUsage = {
8
19
  input: number;
@@ -31,6 +42,12 @@ type UsageRecord = {
31
42
 
32
43
  type Totals = DayUsage;
33
44
 
45
+ type PendingInitialPromptMeasurement = {
46
+ estimate: InitialPromptInputEstimate;
47
+ firstUserTokens: number;
48
+ skipReason?: string;
49
+ };
50
+
34
51
  type PromptInjectionSource = {
35
52
  label: string;
36
53
  chars: number;
@@ -44,6 +61,7 @@ type TokenBreakdownSource = {
44
61
  const DEFAULT_DAYS = 14;
45
62
  const MAX_BAR_WIDTH = 24;
46
63
  const COST_BAR_WIDTH = 10;
64
+ const FIRST_USER_MESSAGE_FRAMING_TOKENS = 16;
47
65
 
48
66
  function addPromptSource(sources: PromptInjectionSource[], label: string, content: string | undefined): number {
49
67
  if (!content) return 0;
@@ -172,22 +190,65 @@ function buildPromptInjectionSources(systemPrompt: string, options: BuildSystemP
172
190
  return sources;
173
191
  }
174
192
 
175
- function formatPromptInjectionLines(systemPrompt: string, options: BuildSystemPromptOptions | null): string[] {
176
- const sources = buildPromptInjectionSources(systemPrompt, options)
177
- .map((source) => ({ ...source, tokens: estimateTokensFromCharCount(source.chars) }))
193
+ function formatTokenCell(tokens: number): string {
194
+ return tokens < 0 ? `-${formatTokens(Math.abs(tokens))}` : formatTokens(tokens);
195
+ }
196
+
197
+ function formatCalibrationSummary(estimate: InitialPromptInputEstimate): string {
198
+ if (estimate.calibrationSamples <= 0) return "uncalibrated";
199
+ const sampleLabel = estimate.calibrationSamples === 1 ? "sample" : "samples";
200
+ return `learned scale ×${estimate.calibrationMultiplier.toFixed(2)} from ${estimate.calibrationSamples} ${sampleLabel}`;
201
+ }
202
+
203
+ function distributeCalibratedTokens<T extends { tokens: number }>(sources: T[], calibratedTotal: number): T[] {
204
+ const uncalibratedTotal = sources.reduce((sum, source) => sum + source.tokens, 0);
205
+ if (uncalibratedTotal <= 0 || calibratedTotal <= 0) return sources.map((source) => ({ ...source, tokens: 0 }));
206
+
207
+ const exact = sources.map((source, index) => {
208
+ const scaled = (source.tokens / uncalibratedTotal) * calibratedTotal;
209
+ const tokens = Math.floor(scaled);
210
+ return { index, tokens, remainder: scaled - tokens };
211
+ });
212
+ let remaining = calibratedTotal - exact.reduce((sum, source) => sum + source.tokens, 0);
213
+ for (const source of [...exact].sort((a, b) => b.remainder - a.remainder || a.index - b.index)) {
214
+ if (remaining <= 0) break;
215
+ source.tokens += 1;
216
+ remaining -= 1;
217
+ }
218
+
219
+ return sources.map((source, index) => ({ ...source, tokens: exact[index]?.tokens ?? 0 }));
220
+ }
221
+
222
+ function formatPromptInjectionLines(systemPrompt: string, options: BuildSystemPromptOptions | null, estimate: InitialPromptInputEstimate): string[] {
223
+ const promptSources = buildPromptInjectionSources(systemPrompt, options)
224
+ .map((source) => ({
225
+ ...source,
226
+ tokens: systemPrompt.length > 0 ? Math.round((source.chars / systemPrompt.length) * estimate.promptText) : estimateTokensFromCharCount(source.chars),
227
+ }))
178
228
  .sort((a, b) => b.tokens - a.tokens || b.chars - a.chars);
179
- const totalTokens = estimatePromptInjectionTokens(systemPrompt);
229
+ const uncalibratedSources = [
230
+ estimate.toolSchemas > 0
231
+ ? { label: `Active tool schemas (${estimate.toolCount})`, chars: 0, tokens: estimate.toolSchemas }
232
+ : null,
233
+ estimate.framing > 0 ? { label: "Provider/request framing", chars: 0, tokens: estimate.framing } : null,
234
+ ...promptSources,
235
+ ].filter((source): source is { label: string; chars: number; tokens: number } => !!source && source.tokens !== 0);
236
+ const sources = distributeCalibratedTokens(uncalibratedSources, estimate.total)
237
+ .filter((source) => source.tokens !== 0)
238
+ .sort((a, b) => Math.abs(b.tokens) - Math.abs(a.tokens) || b.chars - a.chars);
180
239
  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));
240
+ const tokenWidth = Math.max("Tokens".length, ...sources.map((source) => formatTokenCell(source.tokens).length));
182
241
  const percentWidth = "%".length;
183
242
  const separator = `├${"─".repeat(labelWidth + 2)}┼${"─".repeat(tokenWidth + 2)}┼${"─".repeat(percentWidth + 6)}┤`;
184
243
  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)} │`;
244
+ const percent = estimate.total > 0 ? `${((source.tokens / estimate.total) * 100).toFixed(1)}%` : "0.0%";
245
+ return `│ ${source.label.padEnd(labelWidth)} │ ${formatTokenCell(source.tokens).padStart(tokenWidth)} │ ${percent.padStart(percentWidth + 4)} │`;
187
246
  });
247
+ const range = estimate.low !== estimate.high ? ` · range ${formatTokens(estimate.low)}–${formatTokens(estimate.high)}` : "";
188
248
 
189
249
  return [
190
- `Prompt injection: PI: ${formatTokens(totalTokens)} tok`,
250
+ `Prompt injection: PI: ~${formatTokens(estimate.total)} tok initial input (${estimate.confidence}${range})`,
251
+ `Unscaled basis: prompt text ${formatTokens(estimate.promptText)} + tool schemas ${formatTokens(estimate.toolSchemas)} + framing ${formatTokens(estimate.framing)} · displayed rows are proportionally scaled (${formatCalibrationSummary(estimate)})`,
191
252
  `┌${"─".repeat(labelWidth + 2)}┬${"─".repeat(tokenWidth + 2)}┬${"─".repeat(percentWidth + 6)}┐`,
192
253
  `│ ${"Source".padEnd(labelWidth)} │ ${"Tokens".padStart(tokenWidth)} │ ${"%".padStart(percentWidth + 4)} │`,
193
254
  separator,
@@ -606,9 +667,91 @@ function buildCacheEfficiencyLines(totals: Totals): string[] {
606
667
 
607
668
  export default function statsExtension(pi: ExtensionAPI) {
608
669
  let latestSystemPromptOptions: BuildSystemPromptOptions | null = null;
670
+ let pendingInitialPromptMeasurement: PendingInitialPromptMeasurement | null = null;
671
+
672
+ const getToolEstimateInputs = (): { activeTools: string[]; allTools: InitialPromptToolInfo[] } => {
673
+ let activeTools: string[] = [];
674
+ let allTools: InitialPromptToolInfo[] = [];
675
+
676
+ try {
677
+ activeTools = pi.getActiveTools();
678
+ } catch {
679
+ activeTools = [];
680
+ }
681
+
682
+ try {
683
+ allTools = pi.getAllTools().map((tool) => ({
684
+ name: tool.name,
685
+ description: tool.description,
686
+ parameters: tool.parameters,
687
+ }));
688
+ } catch {
689
+ allTools = [];
690
+ }
609
691
 
610
- pi.on("before_agent_start", async (event) => {
692
+ return { activeTools, allTools };
693
+ };
694
+
695
+ const getPromptCalibration = (ctx: ExtensionCommandContext): InitialPromptCalibration | null => {
696
+ return collectInitialPromptCalibration(ctx.sessionManager.getSessionDir());
697
+ };
698
+
699
+ const estimateInitialPromptForContext = (systemPrompt: string, calibration?: InitialPromptCalibration | null): InitialPromptInputEstimate => {
700
+ const { activeTools, allTools } = getToolEstimateInputs();
701
+ return estimateInitialPromptInput({ systemPrompt, activeTools, allTools, calibration });
702
+ };
703
+
704
+ const branchHasAssistantUsage = (ctx: { sessionManager: { getBranch(): unknown[] } }): boolean => {
705
+ try {
706
+ return ctx.sessionManager.getBranch().some((entry) => {
707
+ const record = (entry && typeof entry === "object" ? entry : {}) as Record<string, any>;
708
+ return record.type === "message" && record.message?.role === "assistant" && !!record.message?.usage;
709
+ });
710
+ } catch {
711
+ return true;
712
+ }
713
+ };
714
+
715
+ pi.on("session_start", async () => {
716
+ pendingInitialPromptMeasurement = null;
717
+ });
718
+
719
+ pi.on("before_agent_start", async (event, ctx) => {
611
720
  latestSystemPromptOptions = event.systemPromptOptions;
721
+
722
+ if (!branchHasAssistantUsage(ctx)) {
723
+ pendingInitialPromptMeasurement = {
724
+ estimate: estimateInitialPromptForContext(event.systemPrompt, null),
725
+ firstUserTokens: estimatePromptInjectionTokens(event.prompt) + FIRST_USER_MESSAGE_FRAMING_TOKENS,
726
+ skipReason: event.images && event.images.length > 0 ? "image prompt" : undefined,
727
+ };
728
+ }
729
+ });
730
+
731
+ pi.on("agent_start", async (_event, ctx) => {
732
+ if (!pendingInitialPromptMeasurement || pendingInitialPromptMeasurement.skipReason) return;
733
+ pendingInitialPromptMeasurement.estimate = estimateInitialPromptForContext(ctx.getSystemPrompt(), null);
734
+ });
735
+
736
+ pi.on("message_end", async (event) => {
737
+ if (!pendingInitialPromptMeasurement) return;
738
+ const pending = pendingInitialPromptMeasurement;
739
+ const message = (event.message && typeof event.message === "object" ? event.message : {}) as Record<string, any>;
740
+ if (message.role !== "assistant" || !message.usage) return;
741
+ pendingInitialPromptMeasurement = null;
742
+ if (pending.skipReason) return;
743
+
744
+ const usage = message.usage as Record<string, unknown>;
745
+ const actualInitialInputTokens =
746
+ (Number(usage.input ?? 0) || 0) + (Number(usage.cacheRead ?? 0) || 0) + (Number(usage.cacheWrite ?? 0) || 0);
747
+ const record = buildInitialPromptCalibrationRecord({
748
+ estimate: pending.estimate,
749
+ actualInitialInputTokens,
750
+ firstUserTokens: pending.firstUserTokens,
751
+ provider: String(message.provider ?? "unknown"),
752
+ model: String(message.responseModel ?? message.model ?? "unknown"),
753
+ });
754
+ if (record) appendInitialPromptCalibrationRecord(pi.appendEntry, record);
612
755
  });
613
756
 
614
757
  const showCurrentContextTokens = (ctx: ExtensionCommandContext) => {
@@ -633,8 +776,9 @@ export default function statsExtension(pi: ExtensionAPI) {
633
776
  const byDay = aggregateUsageByDay(records);
634
777
  const dayKeys = getScopeDayKeys(byDay, parsedArgs);
635
778
  const totals = sumUsage(byDay, dayKeys);
779
+ const calibration = collectInitialPromptCalibration(sessionDir);
636
780
  const scopeLabel = parsedArgs.mode === "all" ? "all days" : `last ${parsedArgs.days} days`;
637
- return { files, records, byDay, dayKeys, totals, scopeLabel };
781
+ return { files, records, byDay, dayKeys, totals, calibration, scopeLabel };
638
782
  };
639
783
 
640
784
  const parseStatsCommandArgs = (args: string, ctx: ExtensionCommandContext) => {
@@ -702,14 +846,16 @@ export default function statsExtension(pi: ExtensionAPI) {
702
846
  );
703
847
 
704
848
  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)];
849
+ const promptEstimate = estimateInitialPromptForContext(ctx.getSystemPrompt(), data.calibration);
850
+ return [`📊 Token stats (${data.scopeLabel}, ${data.files.length} sessions) · PI: ~${formatTokens(promptEstimate.total)} tok`, "", ...buildGraphLines(data.byDay, data.dayKeys, true)];
707
851
  });
708
852
 
709
853
  pi.registerCommand("stats-pi", {
710
- description: "Show prompt-injection token breakdown.",
854
+ description: "Show estimated initial prompt input token breakdown.",
711
855
  handler: async (_args, ctx) => {
712
- ctx.ui.notify(formatPromptInjectionLines(ctx.getSystemPrompt(), latestSystemPromptOptions).join("\n"), "info");
856
+ const systemPrompt = ctx.getSystemPrompt();
857
+ const promptEstimate = estimateInitialPromptForContext(systemPrompt, getPromptCalibration(ctx));
858
+ ctx.ui.notify(formatPromptInjectionLines(systemPrompt, latestSystemPromptOptions, promptEstimate).join("\n"), "info");
713
859
  },
714
860
  });
715
861
 
@@ -726,9 +872,9 @@ export default function statsExtension(pi: ExtensionAPI) {
726
872
  if (!data) return;
727
873
 
728
874
  const systemPrompt = ctx.getSystemPrompt();
729
- const promptInjectionTokens = estimatePromptInjectionTokens(systemPrompt);
875
+ const promptEstimate = estimateInitialPromptForContext(systemPrompt, data.calibration);
730
876
  const graphLines = buildGraphLines(data.byDay, data.dayKeys, true);
731
- const promptInjectionLines = formatPromptInjectionLines(systemPrompt, latestSystemPromptOptions);
877
+ const promptInjectionLines = formatPromptInjectionLines(systemPrompt, latestSystemPromptOptions, promptEstimate);
732
878
  const modelLines = formatModelComparisonLines(data.records, data.dayKeys, data.totals).slice(0, 7);
733
879
  const sessionLines = formatExpensiveSessionLines(data.records, data.dayKeys).slice(0, 7);
734
880
  const commandLines = [
@@ -737,7 +883,7 @@ export default function statsExtension(pi: ExtensionAPI) {
737
883
  ];
738
884
 
739
885
  ctx.ui.notify(
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")}`,
886
+ `📊 Token stats (${data.scopeLabel}, ${data.files.length} sessions) · PI: ~${formatTokens(promptEstimate.total)} 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")}`,
741
887
  "info",
742
888
  );
743
889
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-extension-stats",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Token and cost usage analytics command for Pi session history.",
5
5
  "license": "MIT",
6
6
  "keywords": [