@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.
- package/README.md +8 -6
- package/index.ts +353 -16
- 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
|
|
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(
|
|
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
|
-
`
|
|
251
|
-
|
|
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(/</g, "<")
|
|
301
|
+
.replace(/>/g, ">")
|
|
302
|
+
.replace(/"/g, '"')
|
|
303
|
+
.replace(/'/g, "'")
|
|
304
|
+
.replace(/&/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 (
|
|
856
|
-
const
|
|
857
|
-
|
|
858
|
-
|
|
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(
|