@firstpick/pi-extension-stats 0.1.8 → 0.2.0

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 +8 -6
  2. package/index.ts +353 -16
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -26,7 +26,9 @@ No required configuration.
26
26
 
27
27
  - `/stats [days|all]` — show token usage dashboard (default: last 14 days).
28
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.
29
+ - `/stats-pi` — show export-backed estimated initial prompt input token breakdown. It creates a temporary Pi HTML export, decodes its embedded session data, then counts Pi's system prompt text, active provider-level tool schemas, framing overhead, and optional historical calibration (falling back to live context data if export is unavailable).
30
+ - `/stats-pi detailed` — add a concise detail view of the exported initial prompt snapshot: active tool schemas, available-tool prompt entries, skills, context files, metadata, and estimate components.
31
+ - `/calibrate` — start an isolated calibration session with a fixed probe prompt, then update `/stats-pi` and the footer `PI: X tok` estimate from the first assistant response usage. `/calibrate current` reuses the current branch if it already has a suitable first-turn usage sample.
30
32
  - `/stats-last [days|all]` — show non-zero daily usage graph.
31
33
  - `/stats-most-expense [days|all]` — show most expensive sessions.
32
34
  - `/stats-model-compare [days|all]` — show model token/cost comparison.
@@ -35,19 +37,19 @@ No required configuration.
35
37
 
36
38
  ## Prompt input estimate
37
39
 
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.
40
+ `/stats-pi` and the `PI: ~X tok` value in `/stats` estimate the full initial model input, not just raw prompt text. `/stats-pi` prefers Pi's own HTML export data for the exact exported system prompt and active tool definitions; it falls back to live context data when a temporary export cannot be produced, so it can still be run before any LLM prompt in a fresh session.
39
41
 
40
- The calculation is intentionally provider-agnostic:
42
+ The token calculation is intentionally provider-agnostic:
41
43
 
42
44
  ```text
43
- promptTextTokens = weighted text estimate of ctx.getSystemPrompt()
44
- toolSchemaTokens = weighted text estimate of active tool definitions JSON
45
+ promptTextTokens = weighted text estimate of the system prompt (from exported session data when available)
46
+ toolSchemaTokens = weighted text estimate of active tool definitions JSON (from exported session data when available)
45
47
  framingTokens = conservative message/request framing allowance
46
48
  baseEstimate = promptTextTokens + toolSchemaTokens + framingTokens
47
49
  estimatedInitialInput = baseEstimate × historicalCalibrationMultiplier
48
50
  ```
49
51
 
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.
52
+ 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. `/calibrate` performs the same calculation on demand by opening an isolated session and sending a fixed probe prompt; `/calibrate current` can reuse the current branch once its first assistant response has usage data. 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.
51
53
 
52
54
  ## Tools
53
55
 
package/index.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  appendInitialPromptCalibrationRecord,
7
7
  buildInitialPromptCalibrationRecord,
8
8
  collectInitialPromptCalibration,
9
+ estimateInitialPromptFromPiExport,
9
10
  estimateInitialPromptInput,
10
11
  estimatePromptInjectionTokens,
11
12
  estimateTokensFromCharCount,
@@ -62,6 +63,7 @@ const DEFAULT_DAYS = 14;
62
63
  const MAX_BAR_WIDTH = 24;
63
64
  const COST_BAR_WIDTH = 10;
64
65
  const FIRST_USER_MESSAGE_FRAMING_TOKENS = 16;
66
+ const CALIBRATION_PROMPT = "Calibration probe: reply with exactly `calibration-ok` and no other text.";
65
67
 
66
68
  function addPromptSource(sources: PromptInjectionSource[], label: string, content: string | undefined): number {
67
69
  if (!content) return 0;
@@ -194,12 +196,6 @@ function formatTokenCell(tokens: number): string {
194
196
  return tokens < 0 ? `-${formatTokens(Math.abs(tokens))}` : formatTokens(tokens);
195
197
  }
196
198
 
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
199
  function distributeCalibratedTokens<T extends { tokens: number }>(sources: T[], calibratedTotal: number): T[] {
204
200
  const uncalibratedTotal = sources.reduce((sum, source) => sum + source.tokens, 0);
205
201
  if (uncalibratedTotal <= 0 || calibratedTotal <= 0) return sources.map((source) => ({ ...source, tokens: 0 }));
@@ -219,7 +215,12 @@ function distributeCalibratedTokens<T extends { tokens: number }>(sources: T[],
219
215
  return sources.map((source, index) => ({ ...source, tokens: exact[index]?.tokens ?? 0 }));
220
216
  }
221
217
 
222
- function formatPromptInjectionLines(systemPrompt: string, options: BuildSystemPromptOptions | null, estimate: InitialPromptInputEstimate): string[] {
218
+ function formatPromptInjectionLines(
219
+ systemPrompt: string,
220
+ options: BuildSystemPromptOptions | null,
221
+ estimate: InitialPromptInputEstimate,
222
+ metadata?: { source?: string; warning?: string },
223
+ ): string[] {
223
224
  const promptSources = buildPromptInjectionSources(systemPrompt, options)
224
225
  .map((source) => ({
225
226
  ...source,
@@ -246,9 +247,16 @@ function formatPromptInjectionLines(systemPrompt: string, options: BuildSystemPr
246
247
  });
247
248
  const range = estimate.low !== estimate.high ? ` · range ${formatTokens(estimate.low)}–${formatTokens(estimate.high)}` : "";
248
249
 
250
+ const metadataLines = [
251
+ metadata?.source ? `Source: ${metadata.source}` : null,
252
+ metadata?.warning ? `Note: ${metadata.warning}` : null,
253
+ ].filter((line): line is string => !!line);
254
+
255
+ const confidenceLabel = estimate.confidence === "measured-after-call" ? "measured" : `${estimate.confidence} estimate`;
256
+
249
257
  return [
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)})`,
258
+ `PI initial input: ~${formatTokens(estimate.total)} tok (${confidenceLabel}${range})`,
259
+ ...metadataLines,
252
260
  `┌${"─".repeat(labelWidth + 2)}┬${"─".repeat(tokenWidth + 2)}┬${"─".repeat(percentWidth + 6)}┐`,
253
261
  `│ ${"Source".padEnd(labelWidth)} │ ${"Tokens".padStart(tokenWidth)} │ ${"%".padStart(percentWidth + 4)} │`,
254
262
  separator,
@@ -258,6 +266,240 @@ function formatPromptInjectionLines(systemPrompt: string, options: BuildSystemPr
258
266
  }
259
267
 
260
268
 
269
+ type PromptSkillDetail = {
270
+ name: string;
271
+ description?: string;
272
+ location?: string;
273
+ };
274
+
275
+ type ToolPromptEntry = {
276
+ name: string;
277
+ snippet: string;
278
+ };
279
+
280
+ type ContextFileDetail = {
281
+ path: string;
282
+ chars?: number;
283
+ };
284
+
285
+ function shortenText(text: string | undefined, maxLength = 90): string {
286
+ const normalized = (text ?? "").replace(/\s+/g, " ").trim();
287
+ if (normalized.length <= maxLength) return normalized;
288
+ return `${normalized.slice(0, maxLength - 1).trimEnd()}…`;
289
+ }
290
+
291
+ function formatCountedNames(names: string[], limit = 18): string {
292
+ if (names.length === 0) return "none";
293
+ const shown = names.slice(0, limit).join(", ");
294
+ const remaining = names.length - limit;
295
+ return remaining > 0 ? `${shown}, … +${remaining} more` : shown;
296
+ }
297
+
298
+ function xmlUnescape(value: string): string {
299
+ return value
300
+ .replace(/&lt;/g, "<")
301
+ .replace(/&gt;/g, ">")
302
+ .replace(/&quot;/g, '"')
303
+ .replace(/&#39;/g, "'")
304
+ .replace(/&amp;/g, "&");
305
+ }
306
+
307
+ function extractXmlTag(block: string, tag: string): string | undefined {
308
+ const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
309
+ const match = block.match(new RegExp(`<${escapedTag}>([\\s\\S]*?)<\\/${escapedTag}>`, "i"));
310
+ const value = match?.[1]?.trim();
311
+ return value ? xmlUnescape(value) : undefined;
312
+ }
313
+
314
+ function extractAvailableToolPromptEntries(systemPrompt: string): ToolPromptEntry[] {
315
+ const marker = "Available tools:\n";
316
+ const start = systemPrompt.indexOf(marker);
317
+ if (start < 0) return [];
318
+
319
+ const tail = systemPrompt.slice(start + marker.length);
320
+ const blockEnd = tail.search(/\n\n/);
321
+ const block = blockEnd >= 0 ? tail.slice(0, blockEnd) : tail;
322
+ const entries: ToolPromptEntry[] = [];
323
+ const seen = new Set<string>();
324
+
325
+ for (const line of block.split(/\r?\n/)) {
326
+ const match = line.match(/^-\s+([^:\s]+):\s*(.*)$/);
327
+ const name = match?.[1]?.trim();
328
+ if (!name || seen.has(name)) continue;
329
+ seen.add(name);
330
+ entries.push({ name, snippet: match?.[2]?.trim() ?? "" });
331
+ }
332
+
333
+ return entries;
334
+ }
335
+
336
+ function extractPromptSkills(systemPrompt: string, options: BuildSystemPromptOptions | null): PromptSkillDetail[] {
337
+ const blockMatch = systemPrompt.match(/<available_skills>([\s\S]*?)<\/available_skills>/i);
338
+ const block = blockMatch?.[1];
339
+ if (block) {
340
+ const skills: PromptSkillDetail[] = [];
341
+ for (const match of block.matchAll(/<skill>([\s\S]*?)<\/skill>/gi)) {
342
+ const skillBlock = match[1] ?? "";
343
+ const name = extractXmlTag(skillBlock, "name");
344
+ if (!name) continue;
345
+ skills.push({
346
+ name,
347
+ description: extractXmlTag(skillBlock, "description"),
348
+ location: extractXmlTag(skillBlock, "location"),
349
+ });
350
+ }
351
+ if (skills.length > 0) return skills;
352
+ }
353
+
354
+ return (options?.skills ?? [])
355
+ .filter((skill) => !skill.disableModelInvocation)
356
+ .map((skill) => ({ name: skill.name, description: skill.description, location: skill.filePath }));
357
+ }
358
+
359
+ function extractPromptContextFiles(systemPrompt: string, options: BuildSystemPromptOptions | null): ContextFileDetail[] {
360
+ if (options?.contextFiles && options.contextFiles.length > 0) {
361
+ return options.contextFiles.map((file) => ({ path: file.path, chars: file.content.length }));
362
+ }
363
+
364
+ const projectContextStart = systemPrompt.indexOf("\n\n# Project Context\n");
365
+ if (projectContextStart < 0) return [];
366
+
367
+ const skillsStart = systemPrompt.indexOf("\n<available_skills>", projectContextStart);
368
+ const dateStart = systemPrompt.indexOf("\nCurrent date:", projectContextStart);
369
+ const endCandidates = [skillsStart, dateStart].filter((index) => index > projectContextStart);
370
+ const projectContextEnd = endCandidates.length > 0 ? Math.min(...endCandidates) : systemPrompt.length;
371
+ const contextBlock = systemPrompt.slice(projectContextStart, projectContextEnd);
372
+ const contextFiles: ContextFileDetail[] = [];
373
+
374
+ for (const match of contextBlock.matchAll(/^## (.+)$/gm)) {
375
+ const contextPath = match[1]?.trim();
376
+ if (contextPath) contextFiles.push({ path: contextPath });
377
+ }
378
+
379
+ return contextFiles;
380
+ }
381
+
382
+ function extractPromptLineValue(systemPrompt: string, label: string): string | undefined {
383
+ const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
384
+ const match = systemPrompt.match(new RegExp(`^${escapedLabel}:\\s*(.+)$`, "m"));
385
+ return match?.[1]?.trim();
386
+ }
387
+
388
+ function getToolParameterSummary(parameters: unknown): string {
389
+ const record = (parameters && typeof parameters === "object" ? parameters : {}) as Record<string, unknown>;
390
+ const properties = record.properties && typeof record.properties === "object" ? Object.keys(record.properties as Record<string, unknown>).length : 0;
391
+ const required = Array.isArray(record.required) ? record.required.length : 0;
392
+ if (properties <= 0) return "no params";
393
+ return `${properties} param${properties === 1 ? "" : "s"}${required > 0 ? `, ${required} required` : ""}`;
394
+ }
395
+
396
+ function estimateToolSchemaTokens(tool: InitialPromptToolInfo): number {
397
+ return estimatePromptInjectionTokens(
398
+ JSON.stringify({
399
+ name: tool.name,
400
+ description: tool.description ?? "",
401
+ parameters: tool.parameters ?? {},
402
+ }),
403
+ );
404
+ }
405
+
406
+ function pushDetailSection(lines: string[], title: string, body: string[]): void {
407
+ const cleanBody = body.filter((line) => line.trim().length > 0);
408
+ if (cleanBody.length === 0) return;
409
+
410
+ const ruleLength = 52;
411
+ const heading = `╭─ ${title} ${"─".repeat(Math.max(3, ruleLength - title.length))}`;
412
+ lines.push("", heading, ...cleanBody.map((line) => `│ ${line}`), `╰${"─".repeat(Math.max(3, heading.length - 1))}`);
413
+ }
414
+
415
+ function formatInitialPromptDetailedLines(
416
+ promptEstimate: Awaited<ReturnType<typeof estimateInitialPromptFromPiExport>>,
417
+ options: BuildSystemPromptOptions | null,
418
+ ): string[] {
419
+ const systemPrompt = promptEstimate.systemPrompt;
420
+ const estimate = promptEstimate.estimate;
421
+ const tools = promptEstimate.tools;
422
+ const toolPromptEntries = extractAvailableToolPromptEntries(systemPrompt);
423
+ const skills = extractPromptSkills(systemPrompt, options);
424
+ const contextFiles = extractPromptContextFiles(systemPrompt, options);
425
+ const currentDate = extractPromptLineValue(systemPrompt, "Current date");
426
+ const cwd = extractPromptLineValue(systemPrompt, "Current working directory");
427
+ const promptGuidelines = options?.promptGuidelines ?? [];
428
+ const sourceLabel = promptEstimate.source === "export-html" ? "export HTML" : "live context fallback";
429
+ const calibration = estimate.calibrationSamples > 0
430
+ ? `×${estimate.calibrationMultiplier.toFixed(2)} (${estimate.calibrationSamples} sample${estimate.calibrationSamples === 1 ? "" : "s"})`
431
+ : "none";
432
+ const detailLines = ["Initial prompt details", "━━━━━━━━━━━━━━━━━━━━━━"];
433
+
434
+ pushDetailSection(detailLines, "Snapshot", [
435
+ `• source: ${sourceLabel}`,
436
+ `• system prompt: ${systemPrompt.length.toLocaleString()} chars`,
437
+ `• active tool schemas: ${tools.length}`,
438
+ `• available-tool prompt entries: ${toolPromptEntries.length}`,
439
+ `• skills in prompt: ${skills.length}`,
440
+ `• context files: ${contextFiles.length}`,
441
+ ]);
442
+
443
+ pushDetailSection(detailLines, "Estimate components", [
444
+ `• prompt text: ~${formatTokens(estimate.promptText)} tok`,
445
+ `• tool schemas: ~${formatTokens(estimate.toolSchemas)} tok`,
446
+ `• provider/request framing: ~${formatTokens(estimate.framing)} tok`,
447
+ `• calibration: ${calibration}`,
448
+ ]);
449
+
450
+ const metadataParts = [currentDate ? `date: ${currentDate}` : null, cwd ? `cwd: ${cwd}` : null, promptGuidelines.length > 0 ? `extra guidelines: ${promptGuidelines.length}` : null].filter((part): part is string => !!part);
451
+ pushDetailSection(detailLines, "Prompt metadata", metadataParts.map((part) => `• ${part}`));
452
+
453
+ if (tools.length > 0) {
454
+ const toolSummaries = tools
455
+ .map((tool) => ({
456
+ ...tool,
457
+ tokens: estimateToolSchemaTokens(tool),
458
+ parametersSummary: getToolParameterSummary(tool.parameters),
459
+ description: shortenText(tool.description, 72),
460
+ }))
461
+ .sort((a, b) => b.tokens - a.tokens || a.name.localeCompare(b.name));
462
+ const shown = toolSummaries.slice(0, 12);
463
+ const remaining = toolSummaries.length - shown.length;
464
+ const toolLines = shown.map((tool, index) => {
465
+ const description = tool.description ? ` · ${tool.description}` : "";
466
+ return `${String(index + 1).padStart(2, "0")}. ${tool.name} — ~${formatTokens(tool.tokens)} tok · ${tool.parametersSummary}${description}`;
467
+ });
468
+ if (remaining > 0) toolLines.push(`… ${remaining} more active schema${remaining === 1 ? "" : "s"}: ${formatCountedNames(toolSummaries.slice(shown.length).map((tool) => tool.name), 16)}`);
469
+ pushDetailSection(detailLines, `Active tool schemas · top ${shown.length} by size`, toolLines);
470
+ }
471
+
472
+ pushDetailSection(
473
+ detailLines,
474
+ "Available-tools prompt entries",
475
+ toolPromptEntries.length > 0 ? [`• ${formatCountedNames(toolPromptEntries.map((tool) => tool.name), 24)}`] : [],
476
+ );
477
+
478
+ if (skills.length > 0) {
479
+ const shown = skills.slice(0, 10);
480
+ const remaining = skills.length - shown.length;
481
+ const skillLines = shown.map((skill) => {
482
+ const description = skill.description ? ` — ${shortenText(skill.description, 96)}` : "";
483
+ return `• ${skill.name}${description}`;
484
+ });
485
+ if (remaining > 0) skillLines.push(`… ${remaining} more skill${remaining === 1 ? "" : "s"}: ${formatCountedNames(skills.slice(shown.length).map((skill) => skill.name), 16)}`);
486
+ pushDetailSection(detailLines, `Skills in prompt · top ${shown.length}`, skillLines);
487
+ }
488
+
489
+ if (contextFiles.length > 0) {
490
+ const shown = contextFiles.slice(0, 8);
491
+ const remaining = contextFiles.length - shown.length;
492
+ const contextLines = shown.map((file) => {
493
+ const chars = typeof file.chars === "number" ? ` · ${file.chars.toLocaleString()} chars` : "";
494
+ return `• ${file.path}${chars}`;
495
+ });
496
+ if (remaining > 0) contextLines.push(`… ${remaining} more context file${remaining === 1 ? "" : "s"}`);
497
+ pushDetailSection(detailLines, `Context files · ${contextFiles.length}`, contextLines);
498
+ }
499
+
500
+ return detailLines;
501
+ }
502
+
261
503
  function stringifyContextValue(value: unknown): string {
262
504
  if (value === undefined || value === null) return "";
263
505
  if (typeof value === "string") return value;
@@ -373,6 +615,15 @@ function parseDaysArg(args: string): { mode: "range"; days: number } | { mode: "
373
615
  return { mode: "range", days: n };
374
616
  }
375
617
 
618
+ function parseStatsPiArg(args: string): { detailed: boolean } | null {
619
+ const tokens = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
620
+ if (tokens.length === 0) return { detailed: false };
621
+ if (tokens.length === 1 && ["detailed", "detail", "details", "--detailed"].includes(tokens[0] ?? "")) {
622
+ return { detailed: true };
623
+ }
624
+ return null;
625
+ }
626
+
376
627
  function listSessionFiles(sessionDir: string): string[] {
377
628
  try {
378
629
  return fs
@@ -712,6 +963,44 @@ export default function statsExtension(pi: ExtensionAPI) {
712
963
  }
713
964
  };
714
965
 
966
+ const calibrateFromCurrentBranch = (ctx: ExtensionCommandContext): { ok: true; record: NonNullable<ReturnType<typeof buildInitialPromptCalibrationRecord>> } | { ok: false; reason: string } => {
967
+ let firstUserTokens: number | null = null;
968
+ let firstAssistantWithUsage: Record<string, any> | null = null;
969
+
970
+ for (const entry of ctx.sessionManager.getBranch()) {
971
+ const record = (entry && typeof entry === "object" ? entry : {}) as Record<string, any>;
972
+ if (record.type !== "message") continue;
973
+
974
+ const message = (record.message && typeof record.message === "object" ? record.message : {}) as Record<string, any>;
975
+ if (message.role === "user" && firstUserTokens === null) {
976
+ firstUserTokens = estimatePromptInjectionTokens(stringifyContextValue(message.content)) + FIRST_USER_MESSAGE_FRAMING_TOKENS;
977
+ }
978
+ if (message.role === "assistant" && message.usage) {
979
+ firstAssistantWithUsage = message;
980
+ break;
981
+ }
982
+ }
983
+
984
+ if (firstUserTokens === null) return { ok: false, reason: "No initial user message found in the current branch." };
985
+ if (!firstAssistantWithUsage) return { ok: false, reason: "No assistant response with usage data found yet. Run /calibrate after the first assistant response finishes." };
986
+
987
+ const usage = firstAssistantWithUsage.usage as Record<string, unknown>;
988
+ const actualInitialInputTokens =
989
+ (Number(usage.input ?? 0) || 0) + (Number(usage.cacheRead ?? 0) || 0) + (Number(usage.cacheWrite ?? 0) || 0);
990
+ if (actualInitialInputTokens <= 0) return { ok: false, reason: "The first assistant response has no input/cache token usage to calibrate from." };
991
+
992
+ const estimate = estimateInitialPromptForContext(ctx.getSystemPrompt(), null);
993
+ const record = buildInitialPromptCalibrationRecord({
994
+ estimate,
995
+ actualInitialInputTokens,
996
+ firstUserTokens,
997
+ provider: String(firstAssistantWithUsage.provider ?? ctx.model?.provider ?? "unknown"),
998
+ model: String(firstAssistantWithUsage.responseModel ?? firstAssistantWithUsage.model ?? ctx.model?.id ?? "unknown"),
999
+ });
1000
+ if (!record) return { ok: false, reason: "Calibration sample was outside the accepted sanity range (0.25×–4×)." };
1001
+ return { ok: true, record };
1002
+ };
1003
+
715
1004
  pi.on("session_start", async () => {
716
1005
  pendingInitialPromptMeasurement = null;
717
1006
  });
@@ -851,16 +1140,64 @@ export default function statsExtension(pi: ExtensionAPI) {
851
1140
  });
852
1141
 
853
1142
  pi.registerCommand("stats-pi", {
854
- description: "Show estimated initial prompt input token breakdown.",
855
- handler: async (_args, ctx) => {
856
- const systemPrompt = ctx.getSystemPrompt();
857
- const promptEstimate = estimateInitialPromptForContext(systemPrompt, getPromptCalibration(ctx));
858
- ctx.ui.notify(formatPromptInjectionLines(systemPrompt, latestSystemPromptOptions, promptEstimate).join("\n"), "info");
1143
+ description: "Show export-backed estimated initial prompt input token breakdown. Usage: /stats-pi [detailed]",
1144
+ handler: async (args, ctx) => {
1145
+ const parsed = parseStatsPiArg(args);
1146
+ if (!parsed) {
1147
+ ctx.ui.notify("Usage: /stats-pi [detailed]", "warning");
1148
+ return;
1149
+ }
1150
+
1151
+ const promptEstimate = await estimateInitialPromptFromPiExport(pi, ctx, getPromptCalibration(ctx));
1152
+ const lines = formatPromptInjectionLines(promptEstimate.systemPrompt, latestSystemPromptOptions, promptEstimate.estimate, {
1153
+ source: promptEstimate.source === "export-html" ? "temporary Pi /export HTML session data" : "live context fallback",
1154
+ warning: promptEstimate.warning,
1155
+ });
1156
+ if (parsed.detailed) {
1157
+ lines.push("", ...formatInitialPromptDetailedLines(promptEstimate, latestSystemPromptOptions));
1158
+ }
1159
+
1160
+ ctx.ui.notify(lines.join("\n"), "info");
1161
+ },
1162
+ });
1163
+
1164
+ pi.registerCommand("calibrate", {
1165
+ description: "Start an isolated calibration turn to calibrate PI initial prompt token estimates.",
1166
+ handler: async (args, ctx) => {
1167
+ const mode = args.trim().toLowerCase();
1168
+ if (mode === "current" || mode === "here") {
1169
+ const result = calibrateFromCurrentBranch(ctx);
1170
+ if (!result.ok) {
1171
+ ctx.ui.notify(`Calibration failed: ${result.reason}`, "warning");
1172
+ return;
1173
+ }
1174
+
1175
+ appendInitialPromptCalibrationRecord(pi.appendEntry, result.record);
1176
+ const calibration = getPromptCalibration(ctx);
1177
+ const estimate = estimateInitialPromptForContext(ctx.getSystemPrompt(), calibration);
1178
+ ctx.ui.notify(
1179
+ `Calibrated PI estimate: ~${formatTokens(estimate.total)} tok (scale ×${estimate.calibrationMultiplier.toFixed(2)}, ${estimate.calibrationSamples} samples). Run /stats-pi for details.`,
1180
+ "info",
1181
+ );
1182
+ return;
1183
+ }
1184
+
1185
+ if (!ctx.isIdle()) {
1186
+ ctx.ui.notify("Calibration needs an idle agent so it can start a clean probe turn.", "warning");
1187
+ return;
1188
+ }
1189
+
1190
+ ctx.ui.notify("Starting isolated calibration session…", "info");
1191
+ await ctx.newSession({
1192
+ withSession: async (newCtx) => {
1193
+ await newCtx.sendUserMessage(CALIBRATION_PROMPT);
1194
+ },
1195
+ });
859
1196
  },
860
1197
  });
861
1198
 
862
1199
  pi.registerCommand("stats", {
863
- 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",
1200
+ description: "Show token usage dashboard. Usage: /stats, /stats 30, /stats all. Details: /stats-tokens, /stats-pi [detailed], /stats-last, /stats-most-expense, /stats-model-compare, /stats-cost-trend, /stats-cache",
864
1201
  handler: async (args, ctx) => {
865
1202
  const trimmedArgs = args.trim().toLowerCase();
866
1203
  if (trimmedArgs === "tokens") {
@@ -879,7 +1216,7 @@ export default function statsExtension(pi: ExtensionAPI) {
879
1216
  const sessionLines = formatExpensiveSessionLines(data.records, data.dayKeys).slice(0, 7);
880
1217
  const commandLines = [
881
1218
  "Detailed commands:",
882
- "/stats-last · /stats-most-expense · /stats-model-compare · /stats-pi · /stats-cost-trend · /stats-cache · /stats-tokens",
1219
+ "/stats-last · /stats-most-expense · /stats-model-compare · /stats-pi detailed · /stats-cost-trend · /stats-cache · /stats-tokens",
883
1220
  ];
884
1221
 
885
1222
  ctx.ui.notify(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-extension-stats",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "Token and cost usage analytics command for Pi session history.",
5
5
  "license": "MIT",
6
6
  "keywords": [