@pify/usage 0.12.0 → 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
@@ -44,7 +44,7 @@ By project (all time)
44
44
  D--project-shop-api $6.60 · 3.6M tok
45
45
  ```
46
46
 
47
- `/usage export` writes the history out — `by-model.csv`, `by-day.csv`, `by-project.csv` and `totals.json`, full precision — into a timestamped folder under the OS temp directory (or `PIFY_USAGE_EXPORT_DIR`), never into the repository or your home, and prints the path.
47
+ `/usage export` writes the history out — `by-model.csv`, `by-day.csv`, `by-project.csv` and `totals.json`, full precision — into a timestamped folder under the OS temp directory (or `PIFY_USAGE_EXPORT_DIR`), never into the repository or your home, readable by you only, and prints the path.
48
48
 
49
49
  The per-file scan cache is also kept on disk (`usage-scan-cache.json` next to pi's other state, keyed by size and mtime, versioned), so a fresh `pi` no longer pays the whole cold scan on its first `/usage`: it is read once before the first scan and rewritten after each completed one; a corrupt or older file is ignored and rebuilt, and only files the latest scan saw are kept.
50
50
 
@@ -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", {
@@ -332,12 +336,16 @@ export default function usage(pi: ExtensionAPI) {
332
336
  const now = Date.now();
333
337
  const stamp = new Date(now).toISOString().replace(/[:.]/g, "-");
334
338
  const dir = join(process.env.PIFY_USAGE_EXPORT_DIR?.trim() || join(tmpdir(), "pify-usage"), stamp);
339
+ // Spend and project names are the user's business: the folder and
340
+ // files are readable by the user only, and `wx` never follows a link
341
+ // someone else placed at the path (pi 1.0.3's own output-file rule).
342
+ const fileOptions = { mode: 0o600, flag: "wx" } as const;
335
343
  try {
336
- mkdirSync(dir, { recursive: true });
337
- writeFileSync(join(dir, "by-model.csv"), buildByModelCsv(history.byModel));
338
- writeFileSync(join(dir, "by-day.csv"), buildByDayCsv(history.byDay));
339
- writeFileSync(join(dir, "by-project.csv"), buildByProjectCsv(history.byProject));
340
- writeFileSync(join(dir, "totals.json"), buildTotalsJson(history.total, history.files, now));
344
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
345
+ writeFileSync(join(dir, "by-model.csv"), buildByModelCsv(history.byModel), fileOptions);
346
+ writeFileSync(join(dir, "by-day.csv"), buildByDayCsv(history.byDay), fileOptions);
347
+ writeFileSync(join(dir, "by-project.csv"), buildByProjectCsv(history.byProject), fileOptions);
348
+ writeFileSync(join(dir, "totals.json"), buildTotalsJson(history.total, history.files, now), fileOptions);
341
349
  } catch (err) {
342
350
  ctx.ui.notify(`Export failed: ${err instanceof Error ? err.message : String(err)}`, "error");
343
351
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/usage",
3
- "version": "0.12.0",
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 ?? ""),
package/src/sessions.ts CHANGED
@@ -65,7 +65,7 @@ export function persistScanCache(file: string): number {
65
65
  try {
66
66
  mkdirSync(dirname(file), { recursive: true });
67
67
  const tmp = `${file}.${process.pid}.tmp`;
68
- writeFileSync(tmp, JSON.stringify({ version: SCAN_CACHE_VERSION, entries }));
68
+ writeFileSync(tmp, JSON.stringify({ version: SCAN_CACHE_VERSION, entries }), { mode: 0o600 });
69
69
  renameSync(tmp, file);
70
70
  } catch {
71
71
  // An unwritable cache costs the next process one cold scan, nothing else.