@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.
- package/README.md +25 -4
- package/index.ts +164 -18
- 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
|
|
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
|
|
29
|
-
- `/stats
|
|
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 {
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
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) =>
|
|
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 =
|
|
186
|
-
return `│ ${source.label.padEnd(labelWidth)} │ ${
|
|
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:
|
|
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
|
-
|
|
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
|
|
706
|
-
return [`📊 Token stats (${data.scopeLabel}, ${data.files.length} sessions) · PI:
|
|
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
|
|
854
|
+
description: "Show estimated initial prompt input token breakdown.",
|
|
711
855
|
handler: async (_args, ctx) => {
|
|
712
|
-
ctx.
|
|
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
|
|
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:
|
|
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
|
},
|