@pify/usage 0.12.1 → 0.13.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 CHANGED
@@ -64,10 +64,20 @@ Context window: 22.6k of 200.0k used (11%)
64
64
  Tool results ██·························· 8% 16.8k
65
65
  Conversation ···························· 1% 2.4k
66
66
  Free space █████████████████████████··· 89% 177.4k
67
+
68
+ Heaviest tool schemas (14 tools, ~3.9k of the prefix):
69
+ mcp__docs__search 1.6k mcp:docs
70
+ workflow 520
71
+ mcp__docs__fetch 410 mcp:docs
72
+ bash 290
73
+ … and 10 more, ~1.1k
74
+ by source: mcp:docs ~2.0k, builtin/ext ~1.9k
67
75
  ```
68
76
 
69
77
  "Why am I at 60%?" usually has a boring answer — one `read` of a 4,000-line file — and this is where you find it.
70
78
 
79
+ The **Tool definitions** row is broken out below the window into its heaviest tools, biggest first, so a fat MCP schema or a costly server is nameable rather than a lump: every active tool's `name + description + parameters` is a prefix cost paid on *every* request, and the `by source` line totals the builtins and extensions against each MCP server. (This is the live, per-session version of what the cross-harness "subagent-tax" measures.)
80
+
71
81
  Computed entirely from what pi already holds: the assembled system prompt, the files and skills embedded in it, the enabled tool definitions, and the entries that would be sent. No network, no model call.
72
82
 
73
83
  Context files and skills are counted only when their text is genuinely embedded in the prompt, and the system-prompt row is the remainder after subtracting them, so the rows sum to the whole instead of double-counting. When the provider reports more than can be attributed, the difference is shown as **Other** rather than quietly dropped. Image blocks (a `read` of a screenshot, a pasted image) are charged at pi's flat 4,800 chars each, not at the size of their base64 bytes, so one screenshot no longer reads as hundreds of thousands of tokens.
@@ -29,7 +29,7 @@ import { join } from "node:path";
29
29
  import { readFileSync } from "node:fs";
30
30
 
31
31
  import { addRecord, aggregate, recordFromEntry, windowTotals } from "../src/aggregate.ts";
32
- import { buildBreakdown, contextTokensFromUsage, formatBreakdown, resolveUsedTokens } from "../src/context.ts";
32
+ import { buildBreakdown, contextTokensFromUsage, formatBreakdown, formatToolTax, resolveUsedTokens } from "../src/context.ts";
33
33
  import { contextGauge, footerText, formatCost, formatTokens, historyBlock, sessionBlock } from "../src/format.ts";
34
34
  import { childSpendTotal, onChildSpend, resetChildSpend } from "../src/child-cost.ts";
35
35
  import { buildByDayCsv, buildByModelCsv, buildByProjectCsv, buildTotalsJson } from "../src/export.ts";
@@ -281,7 +281,8 @@ export default function usage(pi: ExtensionAPI) {
281
281
  const systemPrompt = host.getSystemPrompt?.() ?? "";
282
282
  const options = host.getSystemPromptOptions?.() ?? {};
283
283
  const selected = new Set(options.selectedTools ?? []);
284
- const allTools = (pi as unknown as { getAllTools?: () => Array<{ name?: string }> }).getAllTools?.() ?? [];
284
+ const allTools =
285
+ (pi as unknown as { getAllTools?: () => Array<{ name?: string; description?: string; parameters?: unknown }> }).getAllTools?.() ?? [];
285
286
  const tools = selected.size > 0 ? allTools.filter((t) => selected.has(t.name ?? "")) : allTools;
286
287
 
287
288
  let skillsText = "";
@@ -316,7 +317,10 @@ export default function usage(pi: ExtensionAPI) {
316
317
  providerTokens: lastPromptTokens || null,
317
318
  estimate: breakdown.attributed,
318
319
  });
319
- return formatBreakdown(breakdown, reported);
320
+ // Break the "Tool definitions" row into its heaviest tools, biggest first,
321
+ // so a costly MCP server or a fat schema is nameable rather than a lump.
322
+ const taxLines = formatToolTax(tools);
323
+ return taxLines.length > 0 ? `${formatBreakdown(breakdown, reported)}\n\n${taxLines.join("\n")}` : formatBreakdown(breakdown, reported);
320
324
  }
321
325
 
322
326
  pi.registerCommand("usage", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/usage",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "Token and cost reporting for pi sessions: live footer, local-history dashboard with per-project costs, opt-in provider quota",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/context.ts CHANGED
@@ -170,6 +170,62 @@ function foldEntries(entries: unknown[]): { conversation: number; toolResults: n
170
170
  return { conversation, toolResults, thinking };
171
171
  }
172
172
 
173
+ /** One tool's share of the schema tax: its estimated token cost and where it came from. */
174
+ export interface ToolTax {
175
+ name: string;
176
+ tokens: number;
177
+ /** "builtin/ext", or "mcp:<server>" for an MCP tool. */
178
+ source: string;
179
+ }
180
+
181
+ /** The server an MCP tool belongs to (mcp__<server>__<tool>), or null. */
182
+ export function mcpServerOf(name: string): string | null {
183
+ const m = /^mcp__(.+?)__/.exec(name);
184
+ return m ? m[1]! : null;
185
+ }
186
+
187
+ /**
188
+ * Every active tool ranked by the tokens its schema (name + description +
189
+ * parameters) adds to the prefix of every request. The "Tool definitions" row
190
+ * in the breakdown is this sum; this says which tools make it up, biggest
191
+ * first — the live, per-session answer to what @pify-adjacent "subagent-tax"
192
+ * measures across harnesses. Same chars/4 estimate as everything else here.
193
+ */
194
+ export function toolTax(tools: BreakdownInput["tools"]): ToolTax[] {
195
+ return tools
196
+ .map((tool) => {
197
+ const name = String(tool.name ?? "");
198
+ const server = mcpServerOf(name);
199
+ return {
200
+ name,
201
+ tokens: estimateTextTokens({ name, description: tool.description, parameters: tool.parameters }),
202
+ source: server ? `mcp:${server}` : "builtin/ext",
203
+ };
204
+ })
205
+ .sort((a, b) => b.tokens - a.tokens || a.name.localeCompare(b.name));
206
+ }
207
+
208
+ /** A compact "heaviest tool schemas" block: the top `max`, then a by-source subtotal. */
209
+ export function formatToolTax(tools: BreakdownInput["tools"], max = 8): string[] {
210
+ const ranked = toolTax(tools);
211
+ if (ranked.length === 0) return [];
212
+ const total = ranked.reduce((sum, t) => sum + t.tokens, 0);
213
+ const width = Math.min(ranked.length, max) > 0 ? ranked.slice(0, max).reduce((m, t) => Math.max(m, t.name.length), 0) : 0;
214
+ const lines = [`Heaviest tool schemas (${ranked.length} ${ranked.length === 1 ? "tool" : "tools"}, ~${formatTokens(total)} of the prefix):`];
215
+ for (const t of ranked.slice(0, max)) {
216
+ lines.push(` ${t.name.padEnd(width)} ${formatTokens(t.tokens).padStart(6)} ${t.source === "builtin/ext" ? "" : t.source}`.trimEnd());
217
+ }
218
+ if (ranked.length > max) {
219
+ const rest = ranked.slice(max).reduce((sum, t) => sum + t.tokens, 0);
220
+ lines.push(` … and ${ranked.length - max} more, ~${formatTokens(rest)}`);
221
+ }
222
+ const bySource = new Map<string, number>();
223
+ for (const t of ranked) bySource.set(t.source, (bySource.get(t.source) ?? 0) + t.tokens);
224
+ const subtotals = [...bySource.entries()].sort((a, b) => b[1] - a[1]).map(([s, n]) => `${s} ~${formatTokens(n)}`);
225
+ if (subtotals.length > 1) lines.push(` by source: ${subtotals.join(", ")}`);
226
+ return lines;
227
+ }
228
+
173
229
  export function buildBreakdown(input: BreakdownInput): ContextBreakdown {
174
230
  const memory = input.contextFiles.reduce(
175
231
  (sum, file) => sum + embeddedTokens(input.systemPrompt, file.content ?? ""),