@firstpick/pi-extension-stats 0.1.9 → 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 +6 -5
  2. package/index.ts +279 -16
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -26,7 +26,8 @@ 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.
30
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.
31
32
  - `/stats-last [days|all]` — show non-zero daily usage graph.
32
33
  - `/stats-most-expense [days|all]` — show most expensive sessions.
@@ -36,13 +37,13 @@ No required configuration.
36
37
 
37
38
  ## Prompt input estimate
38
39
 
39
- `/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.
40
41
 
41
- The calculation is intentionally provider-agnostic:
42
+ The token calculation is intentionally provider-agnostic:
42
43
 
43
44
  ```text
44
- promptTextTokens = weighted text estimate of ctx.getSystemPrompt()
45
- 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)
46
47
  framingTokens = conservative message/request framing allowance
47
48
  baseEstimate = promptTextTokens + toolSchemaTokens + framingTokens
48
49
  estimatedInitialInput = baseEstimate × historicalCalibrationMultiplier
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,
@@ -195,12 +196,6 @@ function formatTokenCell(tokens: number): string {
195
196
  return tokens < 0 ? `-${formatTokens(Math.abs(tokens))}` : formatTokens(tokens);
196
197
  }
197
198
 
198
- function formatCalibrationSummary(estimate: InitialPromptInputEstimate): string {
199
- if (estimate.calibrationSamples <= 0) return "uncalibrated";
200
- const sampleLabel = estimate.calibrationSamples === 1 ? "sample" : "samples";
201
- return `learned scale ×${estimate.calibrationMultiplier.toFixed(2)} from ${estimate.calibrationSamples} ${sampleLabel}`;
202
- }
203
-
204
199
  function distributeCalibratedTokens<T extends { tokens: number }>(sources: T[], calibratedTotal: number): T[] {
205
200
  const uncalibratedTotal = sources.reduce((sum, source) => sum + source.tokens, 0);
206
201
  if (uncalibratedTotal <= 0 || calibratedTotal <= 0) return sources.map((source) => ({ ...source, tokens: 0 }));
@@ -220,7 +215,12 @@ function distributeCalibratedTokens<T extends { tokens: number }>(sources: T[],
220
215
  return sources.map((source, index) => ({ ...source, tokens: exact[index]?.tokens ?? 0 }));
221
216
  }
222
217
 
223
- 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[] {
224
224
  const promptSources = buildPromptInjectionSources(systemPrompt, options)
225
225
  .map((source) => ({
226
226
  ...source,
@@ -247,9 +247,16 @@ function formatPromptInjectionLines(systemPrompt: string, options: BuildSystemPr
247
247
  });
248
248
  const range = estimate.low !== estimate.high ? ` · range ${formatTokens(estimate.low)}–${formatTokens(estimate.high)}` : "";
249
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
+
250
257
  return [
251
- `Prompt injection: PI: ~${formatTokens(estimate.total)} tok initial input (${estimate.confidence}${range})`,
252
- `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,
253
260
  `┌${"─".repeat(labelWidth + 2)}┬${"─".repeat(tokenWidth + 2)}┬${"─".repeat(percentWidth + 6)}┐`,
254
261
  `│ ${"Source".padEnd(labelWidth)} │ ${"Tokens".padStart(tokenWidth)} │ ${"%".padStart(percentWidth + 4)} │`,
255
262
  separator,
@@ -259,6 +266,240 @@ function formatPromptInjectionLines(systemPrompt: string, options: BuildSystemPr
259
266
  }
260
267
 
261
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
+
262
503
  function stringifyContextValue(value: unknown): string {
263
504
  if (value === undefined || value === null) return "";
264
505
  if (typeof value === "string") return value;
@@ -374,6 +615,15 @@ function parseDaysArg(args: string): { mode: "range"; days: number } | { mode: "
374
615
  return { mode: "range", days: n };
375
616
  }
376
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
+
377
627
  function listSessionFiles(sessionDir: string): string[] {
378
628
  try {
379
629
  return fs
@@ -890,11 +1140,24 @@ export default function statsExtension(pi: ExtensionAPI) {
890
1140
  });
891
1141
 
892
1142
  pi.registerCommand("stats-pi", {
893
- description: "Show estimated initial prompt input token breakdown.",
894
- handler: async (_args, ctx) => {
895
- const systemPrompt = ctx.getSystemPrompt();
896
- const promptEstimate = estimateInitialPromptForContext(systemPrompt, getPromptCalibration(ctx));
897
- 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");
898
1161
  },
899
1162
  });
900
1163
 
@@ -934,7 +1197,7 @@ export default function statsExtension(pi: ExtensionAPI) {
934
1197
  });
935
1198
 
936
1199
  pi.registerCommand("stats", {
937
- 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",
938
1201
  handler: async (args, ctx) => {
939
1202
  const trimmedArgs = args.trim().toLowerCase();
940
1203
  if (trimmedArgs === "tokens") {
@@ -953,7 +1216,7 @@ export default function statsExtension(pi: ExtensionAPI) {
953
1216
  const sessionLines = formatExpensiveSessionLines(data.records, data.dayKeys).slice(0, 7);
954
1217
  const commandLines = [
955
1218
  "Detailed commands:",
956
- "/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",
957
1220
  ];
958
1221
 
959
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.9",
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": [