import {Buffer} from 'node:buffer'; import {mkdtemp, writeFile} from 'node:fs/promises'; import path from 'node:path'; import type {ExtensionAPI} from '@earendil-works/pi-coding-agent'; type Part = { name: string; kind: 'Instructions' | 'Tools' | 'Conversation' | 'Request settings'; value: unknown; bytes: number; tokens: number; }; /** Escapes every payload-derived string before inserting it into the offline report. Captured prompts and tool results are untrusted, including markup that might otherwise execute when the user opens the report locally. */ function escapeHtml(value: string): string { return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll('\'', '''); } /** Adds an opt-in, one-shot snapshot and a local context-cost report without making another model request. Each capture lives in a fresh owner-only directory so sensitive history is neither shared nor written through an existing project-controlled symlink. */ export default function captureRequest(pi: ExtensionAPI): void { let isArmed = false; pi.registerCommand('capture-request', { description: 'Capture the next provider request and an offline context-cost report', /** Arms only the next provider request, including a retry if that is what happens next. Capturing does not itself submit a prompt or consume tokens. */ async handler(_args, ctx) { isArmed = true; ctx.ui.notify('The next provider request will be captured with a private HTML context report.', 'info'); }, }); /** Inspects the provider-shaped payload at the request hook without mutating it. Known instruction, tool, and history fields are separated; unknown fields remain visible as settings rather than silently disappearing. Token counts are deliberately labeled proxies: no provider tokenizer or response usage is available at this pre-request boundary. */ pi.on('before_provider_request', async (event, ctx) => { if (!isArmed) { return; } isArmed = false; try { const json = JSON.stringify(event.payload, null, 2); const compact = JSON.stringify(event.payload); if (json === undefined || compact === undefined) { throw new Error('Provider payload is not JSON serializable.'); } const parts: Part[] = []; /** Walks provider containers only far enough to isolate system messages, individual tools, and conversation messages. Keeping each value intact makes schema and message inspection faithful to the captured request. */ function collect(value: unknown, name: string, kind: Part['kind']): void { if (Array.isArray(value) && value.length > 0) { for (const [index, item] of value.entries()) { collect(item, `${name}[${index}]`, kind); } return; } if (value !== null && typeof value === 'object') { if ('role' in value && (value.role === 'system' || value.role === 'developer')) { kind = 'Instructions'; } if ('name' in value && typeof value.name === 'string') { name += ` · ${value.name}`; } else if ('role' in value && typeof value.role === 'string') { name += ` · ${value.role}`; } } const serialized = JSON.stringify(value) ?? 'null'; // ponytail: character-based proxy, not a tokenizer. Provider-specific // token counting is the upgrade path when exact preflight counts matter. const text = typeof value === 'string' ? value : serialized; parts.push({ name, kind, value, bytes: Buffer.byteLength(serialized), tokens: Math.ceil(text.length / 4), }); } if (event.payload !== null && typeof event.payload === 'object' && !Array.isArray(event.payload)) { for (const [key, value] of Object.entries(event.payload)) { if (['instructions', 'system', 'systemInstruction', 'system_instruction'].includes(key)) { collect(value, key, 'Instructions'); } else if (['tools', 'functions', 'toolConfig', 'tool_config'].includes(key)) { collect(value, key, 'Tools'); } else if (['messages', 'input', 'contents'].includes(key)) { collect(value, key, 'Conversation'); } else { collect(value, key, 'Request settings'); } } } else { collect(event.payload, 'Unrecognized payload', 'Request settings'); } const {model} = ctx; const instructions = parts.filter(part => part.kind === 'Instructions'); const instructionTokens = instructions.reduce((sum, part) => sum + part.tokens, 0); const toolTokens = parts.filter(part => part.kind === 'Tools').reduce((sum, part) => sum + part.tokens, 0); const contextTokens = parts.filter(part => part.kind !== 'Request settings').reduce((sum, part) => sum + part.tokens, 0); const baselineTokens = instructionTokens + toolTokens; const inputRate = model?.cost.input; /** Uses Pi's configured USD-per-million rate, not a hard-coded catalog. Missing rates stay unknown; zero rates are shown but explained below because subscription and local models do not imply free API service. */ function cost(tokens: number, rate: number | undefined): string { return rate === undefined || !Number.isFinite(rate) || rate < 0 ? 'Unknown' : `$${(tokens * rate / 1_000_000).toFixed(6)}`; } let rows = ''; let details = ''; const ordered = parts.toSorted((a, b) => b.bytes - a.bytes); for (const part of ordered) { const share = 100 * part.bytes / Buffer.byteLength(compact); rows += `
${escapeHtml(typeof part.value === 'string' ? part.value : JSON.stringify(part.value, null, 2))}${escapeHtml(section)}${escapeHtml(new Date().toISOString())} · ${escapeHtml(model ? `${model.provider} / ${model.id} / ${model.api}` : 'Model unavailable')}
Private snapshot: contains complete prompts, tool schemas, and conversation data. This page runs offline, loads no resources, and makes no model calls. Do not publish it.
Instructions are the instruction-only baseline requested here. Tool definitions are separate, but also consume input context before you ask anything. This snapshot includes current project instructions, skill descriptions, and extension additions wherever the provider places them; it is not a measurement of a pristine Pi installation.
Instructions + tools for 100 uncached requests: ${cost(baselineTokens * 100, inputRate)}; for 1,000: ${cost(baselineTokens * 1000, inputRate)}. This assumes the same baseline each time and excludes conversation growth and output.
Baseline if entirely cache-read: ${cost(baselineTokens, model?.cost.cacheRead)}; if entirely cache-written: ${cost(baselineTokens, model?.cost.cacheWrite)}. These are alternative rate scenarios, not observed cache hits or a billing prediction.
Configured USD / million tokens: input ${inputRate ?? 'unknown'}, cache read ${model?.cost.cacheRead ?? 'unknown'}, cache write ${model?.cost.cacheWrite ?? 'unknown'}, output ${model?.cost.output ?? 'unknown'}. Zero catalog rates may mean unavailable pricing or subscription billing, not free usage.
Context window: ${model?.contextWindow.toLocaleString() ?? 'unknown'} tokens; recognized context proxy: ${model !== undefined && model.contextWindow > 0 ? `${(100 * contextTokens / model.contextWindow).toFixed(1)}%` : 'unknown'}. Configured maximum output: ${model?.maxTokens.toLocaleString() ?? 'unknown'} tokens. Output reservation and provider limits can reduce usable input space.
Byte counts are exact UTF-8 sizes of compact JSON values, including quoting/escaping, not HTTP wire size. Outer field names, separators, and container brackets are not attributed to rows; percentages use the complete compact payload.
| Field / item | Category | Bytes | Payload share | Token proxy | Uncached input |
|---|
Markdown headings split instruction text into inspectable sections. These are raw text byte sizes (without JSON escaping), already included above, not additional context. Headings are not reliable source attribution: extensions can combine or override instructions.
${instructionSections === '' ? 'No recognized instruction text found; inspect the raw payload below.
' : instructionSections}Inspect the Tools entries below for the actual transmitted descriptions, parameters, and schemas. Availability does not imply the model must call a tool. Tool choice settings are shown as request settings. Deferred tools not declared in this payload have no separately measured schema cost here; their discovery instructions may still appear in the prompt.