@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 +11 -1
- package/extensions/usage.ts +16 -8
- package/package.json +1 -1
- package/src/context.ts +56 -0
- package/src/sessions.ts +1 -1
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.
|
package/extensions/usage.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
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.
|