@pify/usage 0.3.0 → 0.4.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 +15 -1
- package/extensions/usage.ts +50 -0
- package/package.json +3 -3
- package/src/context.ts +193 -0
package/README.md
CHANGED
|
@@ -28,6 +28,20 @@ By project (all time)
|
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
- **History done right** (tmustier's lessons): counts every usage-bearing entry in pi's session JSONL — assistant turns plus the tool-result/compaction usage pi 0.81+ persists; negative/NaN fields clamp to zero; days are your local calendar days; a per-file mtime cache keeps repeat scans instant.
|
|
31
|
+
- **Where the window went** (v0.4): `/usage context` breaks the context window into system prompt, context files, skills, tool definitions, tool results, and conversation — so "why am I at 60%?" has an answer that is usually "one `read` of a 4,000-line file", not a mystery.
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
Context window: 22.6k of 200.0k used (11%)
|
|
35
|
+
System prompt ···························· <1% 11
|
|
36
|
+
Context files ···························· 1% 3.0k
|
|
37
|
+
Tool definitions ···························· <1% 371
|
|
38
|
+
Tool results ██·························· 8% 16.8k
|
|
39
|
+
Conversation ···························· 1% 2.4k
|
|
40
|
+
Free space █████████████████████████··· 89% 177.4k
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Computed 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. 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 we can attribute, the difference is shown as **Other** rather than dropped. (The idea is from [`pi-cc-extensions`](https://github.com/minuque/pi-cc-extensions)' `/context`.)
|
|
44
|
+
|
|
31
45
|
- **Per-project spend** (v0.2): pi stores sessions one directory per project, so the dashboard can show where the money actually went — the top 5 projects by cost, all time.
|
|
32
46
|
- **`usage_status` tool**: the agent can check session + today totals before committing to expensive work (subagent fan-outs, large reads).
|
|
33
47
|
|
|
@@ -36,7 +50,7 @@ By project (all time)
|
|
|
36
50
|
The one command in this package that touches the network, and only when you run it:
|
|
37
51
|
|
|
38
52
|
```
|
|
39
|
-
Quota (openrouter · sk-or-v1-
|
|
53
|
+
Quota (openrouter · sk-or-v1-abc...xyz)
|
|
40
54
|
spent $0.32 (no credit limit on this key)
|
|
41
55
|
window day $0.32 · week $0.32 · month $0.32
|
|
42
56
|
```
|
package/extensions/usage.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* is the one that reports a real balance rather than an opaque window.
|
|
19
19
|
*/
|
|
20
20
|
import {
|
|
21
|
+
formatSkillsForPrompt,
|
|
21
22
|
getAgentDir,
|
|
22
23
|
type ExtensionAPI,
|
|
23
24
|
type ExtensionContext,
|
|
@@ -27,6 +28,7 @@ import { join } from "node:path";
|
|
|
27
28
|
import { readFileSync } from "node:fs";
|
|
28
29
|
|
|
29
30
|
import { addRecord, aggregate, recordFromEntry, windowTotals } from "../src/aggregate.ts";
|
|
31
|
+
import { buildBreakdown, formatBreakdown } from "../src/context.ts";
|
|
30
32
|
import { footerText, formatCost, formatTokens, historyBlock, sessionBlock } from "../src/format.ts";
|
|
31
33
|
import { fetchOpenRouterQuota, quotaBlock } from "../src/quota.ts";
|
|
32
34
|
import { scanSessions } from "../src/sessions.ts";
|
|
@@ -106,6 +108,54 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
106
108
|
return process.env.OPENROUTER_API_KEY ?? "";
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Where the context window went, computed from what pi already holds:
|
|
113
|
+
* the assembled system prompt, the context files and skills embedded in
|
|
114
|
+
* it, the enabled tool definitions, and the entries that would be sent.
|
|
115
|
+
* No network, no model call — same rule as the rest of the package.
|
|
116
|
+
*/
|
|
117
|
+
function contextBreakdown(ctx: UiContext): string {
|
|
118
|
+
const host = ctx as unknown as {
|
|
119
|
+
getSystemPrompt?: () => string;
|
|
120
|
+
getSystemPromptOptions?: () => {
|
|
121
|
+
contextFiles?: Array<{ path?: string; content?: string }>;
|
|
122
|
+
skills?: unknown[];
|
|
123
|
+
selectedTools?: string[];
|
|
124
|
+
};
|
|
125
|
+
getContextUsage?: () => { contextWindow?: number; used?: number; total?: number } | undefined;
|
|
126
|
+
sessionManager?: { buildContextEntries?: () => unknown[]; getBranch?: () => unknown[] };
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const systemPrompt = host.getSystemPrompt?.() ?? "";
|
|
130
|
+
const options = host.getSystemPromptOptions?.() ?? {};
|
|
131
|
+
const selected = new Set(options.selectedTools ?? []);
|
|
132
|
+
const allTools = (pi as unknown as { getAllTools?: () => Array<{ name?: string }> }).getAllTools?.() ?? [];
|
|
133
|
+
const tools = selected.size > 0 ? allTools.filter((t) => selected.has(t.name ?? "")) : allTools;
|
|
134
|
+
|
|
135
|
+
let skillsText = "";
|
|
136
|
+
try {
|
|
137
|
+
skillsText = formatSkillsForPrompt((options.skills ?? []) as never).trim();
|
|
138
|
+
} catch {
|
|
139
|
+
// A pi version that formats skills differently just reports 0 here.
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const entries = host.sessionManager?.buildContextEntries?.() ?? host.sessionManager?.getBranch?.() ?? [];
|
|
143
|
+
const usage = host.getContextUsage?.();
|
|
144
|
+
const contextWindow =
|
|
145
|
+
usage?.contextWindow ?? (ctx.model as { contextWindow?: number } | null)?.contextWindow ?? 0;
|
|
146
|
+
|
|
147
|
+
const breakdown = buildBreakdown({
|
|
148
|
+
systemPrompt,
|
|
149
|
+
contextFiles: options.contextFiles ?? [],
|
|
150
|
+
skillsText,
|
|
151
|
+
tools,
|
|
152
|
+
entries,
|
|
153
|
+
contextWindow,
|
|
154
|
+
});
|
|
155
|
+
const reported = typeof usage?.used === "number" ? usage.used : lastPromptTokens || null;
|
|
156
|
+
return formatBreakdown(breakdown, reported);
|
|
157
|
+
}
|
|
158
|
+
|
|
109
159
|
pi.registerCommand("usage", {
|
|
110
160
|
description: "Token and cost dashboard: /usage [quota]",
|
|
111
161
|
handler: async (args, ctx) => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pify/usage",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Token and cost reporting for pi sessions: live footer,
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
7
7
|
"pi-extension",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
}
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
61
|
+
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
62
62
|
"@types/node": "^22.10.2",
|
|
63
63
|
"typebox": "^1.1.38",
|
|
64
64
|
"typescript": "^5.7.2"
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the context window actually went (the idea, and the accounting
|
|
3
|
+
* discipline, are from minuque/pi-cc-extensions' /context).
|
|
4
|
+
*
|
|
5
|
+
* The rest of this package answers "what have I spent". This answers the
|
|
6
|
+
* other question you have at 60% context: "spent on WHAT". Both are computed
|
|
7
|
+
* from what pi already has in memory — no network, no model call — so the
|
|
8
|
+
* numbers are estimates (chars/4, the same estimate pi uses for compaction),
|
|
9
|
+
* not provider billing.
|
|
10
|
+
*
|
|
11
|
+
* Two rules keep the estimate honest:
|
|
12
|
+
* - context files and skills are counted only when their text is actually
|
|
13
|
+
* embedded in the assembled system prompt, so a file that was loaded but
|
|
14
|
+
* not injected does not appear twice;
|
|
15
|
+
* - the system-prompt row is the remainder after those are subtracted, so
|
|
16
|
+
* the parts sum to the whole instead of overlapping.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface ContextPart {
|
|
20
|
+
label: string;
|
|
21
|
+
tokens: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ContextBreakdown {
|
|
25
|
+
parts: ContextPart[];
|
|
26
|
+
/** Sum of every attributed part. */
|
|
27
|
+
attributed: number;
|
|
28
|
+
contextWindow: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** pi's own compaction estimate: four characters per token. */
|
|
32
|
+
export function estimateTextTokens(value: unknown): number {
|
|
33
|
+
if (value === null || value === undefined || value === "") return 0;
|
|
34
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
35
|
+
return Math.max(0, Math.ceil(text.length / 4));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Count a chunk only when the assembled prompt really contains it. */
|
|
39
|
+
export function embeddedTokens(systemPrompt: string, chunk: string): number {
|
|
40
|
+
if (!chunk || !systemPrompt.includes(chunk)) return 0;
|
|
41
|
+
return estimateTextTokens(chunk);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface BreakdownInput {
|
|
45
|
+
systemPrompt: string;
|
|
46
|
+
/** Context files (AGENTS.md, CLAUDE.md, …) pi loaded for the prompt. */
|
|
47
|
+
contextFiles: Array<{ path?: string; content?: string }>;
|
|
48
|
+
/** Skills text as pi formats it into the prompt. */
|
|
49
|
+
skillsText: string;
|
|
50
|
+
/** Definitions of the tools actually enabled this turn. */
|
|
51
|
+
tools: Array<{ name?: string; description?: string; parameters?: unknown }>;
|
|
52
|
+
/** Entries pi would send as conversation this turn. */
|
|
53
|
+
entries: unknown[];
|
|
54
|
+
contextWindow: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
58
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Split the conversation into what the agent said and what tools returned.
|
|
63
|
+
* Tool results are the part that grows without anyone deciding it should,
|
|
64
|
+
* which is exactly why they deserve their own row.
|
|
65
|
+
*/
|
|
66
|
+
function foldEntries(entries: unknown[]): { conversation: number; toolResults: number } {
|
|
67
|
+
let conversation = 0;
|
|
68
|
+
let toolResults = 0;
|
|
69
|
+
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
if (!isRecord(entry)) continue;
|
|
72
|
+
const message = isRecord(entry.message) ? entry.message : entry;
|
|
73
|
+
const role = typeof message.role === "string" ? message.role : null;
|
|
74
|
+
|
|
75
|
+
if (role === "toolResult" || role === "bashExecution") {
|
|
76
|
+
toolResults += estimateTextTokens(message.content ?? message.output ?? message);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (role === "assistant" && Array.isArray(message.content)) {
|
|
80
|
+
for (const block of message.content as Array<Record<string, unknown>>) {
|
|
81
|
+
if (!isRecord(block)) continue;
|
|
82
|
+
if (block.type === "toolCall") {
|
|
83
|
+
conversation += estimateTextTokens(block.name) + estimateTextTokens(block.arguments);
|
|
84
|
+
} else if (block.type === "text") {
|
|
85
|
+
conversation += estimateTextTokens(block.text);
|
|
86
|
+
} else if (block.type === "thinking") {
|
|
87
|
+
conversation += estimateTextTokens(block.thinking);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (typeof entry.summary === "string") {
|
|
93
|
+
conversation += estimateTextTokens(entry.summary);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (typeof entry.content === "string" || Array.isArray(entry.content)) {
|
|
97
|
+
conversation += estimateTextTokens(entry.content);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (role) conversation += estimateTextTokens(message.content ?? message);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { conversation, toolResults };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function buildBreakdown(input: BreakdownInput): ContextBreakdown {
|
|
107
|
+
const memory = input.contextFiles.reduce(
|
|
108
|
+
(sum, file) => sum + embeddedTokens(input.systemPrompt, file.content ?? ""),
|
|
109
|
+
0,
|
|
110
|
+
);
|
|
111
|
+
const skills = embeddedTokens(input.systemPrompt, input.skillsText.trim());
|
|
112
|
+
const tools = input.tools.reduce(
|
|
113
|
+
(sum, tool) =>
|
|
114
|
+
sum +
|
|
115
|
+
estimateTextTokens({
|
|
116
|
+
name: tool.name,
|
|
117
|
+
description: tool.description,
|
|
118
|
+
parameters: tool.parameters,
|
|
119
|
+
}),
|
|
120
|
+
0,
|
|
121
|
+
);
|
|
122
|
+
const { conversation, toolResults } = foldEntries(input.entries);
|
|
123
|
+
// Memory and skills live inside the prompt; subtract so rows do not overlap.
|
|
124
|
+
const system = Math.max(0, estimateTextTokens(input.systemPrompt) - memory - skills);
|
|
125
|
+
|
|
126
|
+
const parts: ContextPart[] = [
|
|
127
|
+
{ label: "System prompt", tokens: system },
|
|
128
|
+
{ label: "Context files", tokens: memory },
|
|
129
|
+
{ label: "Skills", tokens: skills },
|
|
130
|
+
{ label: "Tool definitions", tokens: tools },
|
|
131
|
+
{ label: "Tool results", tokens: toolResults },
|
|
132
|
+
{ label: "Conversation", tokens: conversation },
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
parts,
|
|
137
|
+
attributed: parts.reduce((sum, part) => sum + part.tokens, 0),
|
|
138
|
+
contextWindow: Math.max(0, input.contextWindow),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const BAR_WIDTH = 28;
|
|
143
|
+
|
|
144
|
+
function bar(fraction: number): string {
|
|
145
|
+
const filled = Math.max(0, Math.min(BAR_WIDTH, Math.round(fraction * BAR_WIDTH)));
|
|
146
|
+
return `${"█".repeat(filled)}${"·".repeat(BAR_WIDTH - filled)}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function pct(tokens: number, total: number): string {
|
|
150
|
+
if (total <= 0) return " — ";
|
|
151
|
+
const value = (tokens / total) * 100;
|
|
152
|
+
if (value > 0 && value < 0.5) return " <1%";
|
|
153
|
+
return `${Math.round(value).toString().padStart(3)}%`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function formatTokens(n: number): string {
|
|
157
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
158
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
159
|
+
return String(Math.round(n));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Render the breakdown. `reportedUsed` is the provider's own number for the
|
|
164
|
+
* last request when pi has one: it is authoritative, so anything it counts
|
|
165
|
+
* that the parts do not is shown as "Other" rather than silently dropped.
|
|
166
|
+
*/
|
|
167
|
+
export function formatBreakdown(breakdown: ContextBreakdown, reportedUsed: number | null): string {
|
|
168
|
+
const window = breakdown.contextWindow;
|
|
169
|
+
const used = Math.max(reportedUsed ?? 0, breakdown.attributed);
|
|
170
|
+
const other = Math.max(0, used - breakdown.attributed);
|
|
171
|
+
const free = window > 0 ? Math.max(0, window - used) : 0;
|
|
172
|
+
|
|
173
|
+
const rows = [...breakdown.parts];
|
|
174
|
+
if (other > 0) rows.push({ label: "Other", tokens: other });
|
|
175
|
+
if (window > 0) rows.push({ label: "Free space", tokens: free });
|
|
176
|
+
|
|
177
|
+
const width = rows.reduce((m, row) => Math.max(m, row.label.length), 0);
|
|
178
|
+
const denominator = window > 0 ? window : used;
|
|
179
|
+
|
|
180
|
+
const lines = [
|
|
181
|
+
window > 0
|
|
182
|
+
? `Context window: ${formatTokens(used)} of ${formatTokens(window)} used (${pct(used, window).trim()})`
|
|
183
|
+
: `Context: ${formatTokens(used)} used (no window reported)`,
|
|
184
|
+
];
|
|
185
|
+
for (const row of rows) {
|
|
186
|
+
if (row.tokens === 0 && row.label !== "Free space") continue;
|
|
187
|
+
lines.push(
|
|
188
|
+
` ${row.label.padEnd(width)} ${bar(denominator > 0 ? row.tokens / denominator : 0)} ${pct(row.tokens, denominator)} ${formatTokens(row.tokens)}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
lines.push("Estimated locally (≈4 chars/token); provider billing may differ.");
|
|
192
|
+
return lines.join("\n");
|
|
193
|
+
}
|