@pify/usage 0.9.1 → 0.10.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 +3 -1
- package/extensions/usage.ts +28 -3
- package/package.json +1 -1
- package/src/aggregate.ts +19 -5
- package/src/child-cost.ts +78 -0
- package/src/format.ts +20 -4
package/README.md
CHANGED
|
@@ -18,6 +18,8 @@ Spend is invisible until the invoice arrives, and by then you cannot tell which
|
|
|
18
18
|
|
|
19
19
|
Folded from each message's `usage.cost`, which pi already computes. It survives `/reload` by replaying the session branch rather than keeping a running total in memory.
|
|
20
20
|
|
|
21
|
+
When [`@pify/subagent`](https://github.com/pifydev/subagent), [`@pify/swarm`](https://github.com/pifydev/swarm) or [`@pify/workflow`](https://github.com/pifydev/workflow) run child agents, those children are separate in-memory sessions whose spend never reaches this branch — a session that delegates used to look cheaper than it was. Those packages now add every child message's cost to a process-wide tally, and the footer shows it as a separate ` · agents $0.12` beside the session's own figure (the `/usage` session block has the same line, with tokens). It is kept apart on purpose: the session numbers stay what the branch says, and the agents figure is what the delegation cost on top. The tally survives `/reload` and starts over with a new session.
|
|
22
|
+
|
|
21
23
|
The gauge on the right is how full the context window is — the third mid-session question, next to tokens and cost. It was computed for the `/usage` dashboard but shown only there; now it is live. It appears once there is a window to measure against (so not under `-p`) and turns to `⚠` past 90%, the point where "how full" stops being trivia and becomes a decision. For the breakdown of what filled it, run `/context`.
|
|
22
24
|
|
|
23
25
|
## `/usage`
|
|
@@ -40,7 +42,7 @@ By project (all time)
|
|
|
40
42
|
D--project-shop-api $6.60 · 3.6M tok
|
|
41
43
|
```
|
|
42
44
|
|
|
43
|
-
History counts every usage-bearing entry in pi's session JSONL — assistant turns plus the tool-result usage pi persists, and the summariser's own call, which pi records on the compaction and branch-summary entries themselves rather than on a message. Those summary calls are among the most expensive in a session, so counting them is what makes a session's total match what you were billed rather than what the visible messages add up to; they have no model of their own, so they group under a `compaction` / `branch_summary` label in the by-model view. The live footer and session totals fold the same summariser usage in as it happens (on compaction and tree navigation), so the number does not jump only after a reload. Negative and NaN fields clamp to zero, days are your **local** calendar days, and a per-file mtime cache keeps repeat scans instant.
|
|
45
|
+
History counts every usage-bearing entry in pi's session JSONL — assistant turns plus the tool-result usage pi persists, and the summariser's own call, which pi records on the compaction and branch-summary entries themselves rather than on a message. Those summary calls are among the most expensive in a session, so counting them is what makes a session's total match what you were billed rather than what the visible messages add up to; they have no model of their own, so they group under a `compaction` / `branch_summary` label in the by-model view. The live footer and session totals fold the same summariser usage in as it happens (on compaction and tree navigation), so the number does not jump only after a reload. pi 0.87's cache warmer spends real money outside any message too, as standalone `usage` entries; those are counted under their own model on reload and in history (pi raises no extension event for them, so the live footer picks them up at the next session start). Negative and NaN fields clamp to zero, days are your **local** calendar days, and a per-file mtime cache keeps repeat scans instant.
|
|
44
46
|
|
|
45
47
|
Per-project totals come for free: pi stores sessions one directory per project, so the dashboard can show where the money actually went.
|
|
46
48
|
|
package/extensions/usage.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { readFileSync } from "node:fs";
|
|
|
31
31
|
import { addRecord, aggregate, recordFromEntry, windowTotals } from "../src/aggregate.ts";
|
|
32
32
|
import { buildBreakdown, contextTokensFromUsage, formatBreakdown, resolveUsedTokens } from "../src/context.ts";
|
|
33
33
|
import { contextGauge, footerText, formatCost, formatTokens, historyBlock, sessionBlock } from "../src/format.ts";
|
|
34
|
+
import { childSpendTotal, onChildSpend, resetChildSpend } from "../src/child-cost.ts";
|
|
34
35
|
import { QUOTA_PROVIDERS, fetchQuota, quotaReport, type QuotaResult } from "../src/quota.ts";
|
|
35
36
|
import { redact } from "../src/redact.ts";
|
|
36
37
|
import { scanSessions } from "../src/sessions.ts";
|
|
@@ -66,9 +67,17 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
66
67
|
if (snap) rateLimits.set(provider, snap);
|
|
67
68
|
});
|
|
68
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The ctx the footer is drawn into, for the child-spend listener: a child
|
|
72
|
+
* finishing a turn fires no pi event on the parent, so the tally calls back
|
|
73
|
+
* and the footer redraws into the latest session context.
|
|
74
|
+
*/
|
|
75
|
+
let footerCtx: UiContext | null = null;
|
|
76
|
+
let stopChildSpend: (() => void) | null = null;
|
|
77
|
+
|
|
69
78
|
function updateFooter(ctx: UiContext): void {
|
|
70
79
|
if (!ctx.hasUI) return;
|
|
71
|
-
const base = footerText(session);
|
|
80
|
+
const base = footerText(session, childSpendTotal());
|
|
72
81
|
if (!base) {
|
|
73
82
|
ctx.ui.setStatus("usage", undefined);
|
|
74
83
|
return;
|
|
@@ -105,7 +114,7 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
105
114
|
const scan = scanSessions(join(getAgentDir(), "sessions"));
|
|
106
115
|
return [scan.records, scan.files] as const;
|
|
107
116
|
})());
|
|
108
|
-
return [sessionBlock(session, contextInfo(ctx).pct), historyBlock(history, Date.now())].join("\n\n");
|
|
117
|
+
return [sessionBlock(session, contextInfo(ctx).pct, childSpendTotal()), historyBlock(history, Date.now())].join("\n\n");
|
|
109
118
|
}
|
|
110
119
|
|
|
111
120
|
// ── Live tracking ────────────────────────────────────────────────────
|
|
@@ -124,10 +133,23 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
124
133
|
updateFooter(ctx);
|
|
125
134
|
});
|
|
126
135
|
|
|
127
|
-
pi.on("session_start", async (
|
|
136
|
+
pi.on("session_start", async (event, ctx) => {
|
|
128
137
|
// Rebuild the session totals from the branch so /reload keeps the count.
|
|
129
138
|
session = emptyTotals();
|
|
130
139
|
lastPromptTokens = 0;
|
|
140
|
+
// Child spend is not in the branch, so a /reload must keep the tally; a
|
|
141
|
+
// new, resumed or forked session starts its own.
|
|
142
|
+
if ((event as { reason?: string }).reason !== "reload") resetChildSpend();
|
|
143
|
+
footerCtx = ctx;
|
|
144
|
+
stopChildSpend?.();
|
|
145
|
+
stopChildSpend = onChildSpend(() => {
|
|
146
|
+
if (!footerCtx) return;
|
|
147
|
+
try {
|
|
148
|
+
updateFooter(footerCtx);
|
|
149
|
+
} catch {
|
|
150
|
+
// a stale ctx after a switch; the next session_start rebinds it
|
|
151
|
+
}
|
|
152
|
+
});
|
|
131
153
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
132
154
|
const record = recordFromEntry(entry);
|
|
133
155
|
if (record) addRecord(session, record);
|
|
@@ -161,6 +183,9 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
161
183
|
});
|
|
162
184
|
|
|
163
185
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
186
|
+
stopChildSpend?.();
|
|
187
|
+
stopChildSpend = null;
|
|
188
|
+
footerCtx = null;
|
|
164
189
|
if (ctx.hasUI) ctx.ui.setStatus("usage", undefined);
|
|
165
190
|
});
|
|
166
191
|
|
package/package.json
CHANGED
package/src/aggregate.ts
CHANGED
|
@@ -76,18 +76,24 @@ export function windowTotals(byDay: Map<string, UsageTotals>, days: number, now:
|
|
|
76
76
|
* summariser's usage one level up, on the compaction / branch_summary entry
|
|
77
77
|
* itself (these entries have no `message`), so those are read from
|
|
78
78
|
* `entry.usage` — otherwise the most expensive calls in a session go
|
|
79
|
-
* uncounted.
|
|
80
|
-
*
|
|
79
|
+
* uncounted. pi 0.87's standalone `usage` entries (cache warming) also carry
|
|
80
|
+
* their usage on the entry. Returns null for entries without usage;
|
|
81
|
+
* negative/NaN fields clamp to 0.
|
|
81
82
|
*/
|
|
82
83
|
export function recordFromEntry(entry: unknown): UsageRecord | null {
|
|
83
84
|
if (!isRecord(entry)) return null;
|
|
84
85
|
const message = isRecord(entry.message) ? entry.message : null;
|
|
85
86
|
const entryType = typeof entry.type === "string" ? entry.type : "";
|
|
86
87
|
const isSummary = entryType === "compaction" || entryType === "branch_summary";
|
|
88
|
+
// pi 0.87 adds a standalone UsageEntry ({type:"usage", kind:"cache_warm",
|
|
89
|
+
// provider, model, usage}) for spend that belongs to no message — the cache
|
|
90
|
+
// warmer writes one per refresh, and pi folds it into its own session
|
|
91
|
+
// totals. It carries its own provider/model, unlike the summaries.
|
|
92
|
+
const isUsageEntry = entryType === "usage";
|
|
87
93
|
const usage =
|
|
88
94
|
message && isRecord(message.usage)
|
|
89
95
|
? message.usage
|
|
90
|
-
: isSummary && isRecord(entry.usage)
|
|
96
|
+
: (isSummary || isUsageEntry) && isRecord(entry.usage)
|
|
91
97
|
? entry.usage
|
|
92
98
|
: null;
|
|
93
99
|
if (!usage) return null;
|
|
@@ -105,9 +111,17 @@ export function recordFromEntry(entry: unknown): UsageRecord | null {
|
|
|
105
111
|
|
|
106
112
|
// A summary entry has no model/provider of its own; label the bucket by the
|
|
107
113
|
// entry type rather than threading a running "last model" through the scan.
|
|
114
|
+
// A usage entry names its model/provider itself; fall back to its kind.
|
|
115
|
+
const own = message ?? (isUsageEntry ? entry : null);
|
|
108
116
|
const model =
|
|
109
|
-
|
|
110
|
-
|
|
117
|
+
own && typeof own.model === "string"
|
|
118
|
+
? own.model
|
|
119
|
+
: isSummary
|
|
120
|
+
? entryType
|
|
121
|
+
: isUsageEntry && typeof entry.kind === "string"
|
|
122
|
+
? entry.kind
|
|
123
|
+
: "unknown";
|
|
124
|
+
const provider = own && typeof own.provider === "string" ? own.provider : "unknown";
|
|
111
125
|
|
|
112
126
|
return {
|
|
113
127
|
timestamp: Number.isFinite(ts) ? ts : 0,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the children cost, across every @pify extension in the process.
|
|
3
|
+
*
|
|
4
|
+
* subagent, swarm and workflow run their children as separate in-memory pi
|
|
5
|
+
* sessions, so a child's spend never reaches the parent session's branch —
|
|
6
|
+
* and the usage footer, which folds the branch, under-reports every session
|
|
7
|
+
* that delegates. This is the one place they all add to and the footer reads.
|
|
8
|
+
*
|
|
9
|
+
* The state lives on `globalThis` under a cross-realm `Symbol.for` key, not
|
|
10
|
+
* in a module variable: pi's loader gives each extension its own module realm
|
|
11
|
+
* (jiti, `moduleCache: false`), so a module singleton would be a different
|
|
12
|
+
* object in every package that vendors this file. Same reasoning as
|
|
13
|
+
* ui-lock.ts. Vendored per package, byte-identical, zero dependencies; every
|
|
14
|
+
* function is safe to call whether or not any other package is installed.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const KEY = Symbol.for("pify.child-cost");
|
|
18
|
+
|
|
19
|
+
export interface ChildSpend {
|
|
20
|
+
/** USD, as pi priced each child message (usage.cost.total). */
|
|
21
|
+
cost: number;
|
|
22
|
+
/** usage.totalTokens summed over child messages. */
|
|
23
|
+
tokens: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Store {
|
|
27
|
+
bySource: Map<string, ChildSpend>;
|
|
28
|
+
listeners: Set<() => void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function store(): Store {
|
|
32
|
+
const g = globalThis as unknown as { [KEY]?: Store };
|
|
33
|
+
return (g[KEY] ??= { bySource: new Map(), listeners: new Set() });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Record one child message's spend under its package ("subagent", "swarm", "workflow"). Non-finite or negative parts are ignored. */
|
|
37
|
+
export function addChildSpend(source: string, spend: { cost?: number; tokens?: number }): void {
|
|
38
|
+
const cost = typeof spend.cost === "number" && Number.isFinite(spend.cost) && spend.cost > 0 ? spend.cost : 0;
|
|
39
|
+
const tokens =
|
|
40
|
+
typeof spend.tokens === "number" && Number.isFinite(spend.tokens) && spend.tokens > 0 ? Math.round(spend.tokens) : 0;
|
|
41
|
+
if (cost === 0 && tokens === 0) return;
|
|
42
|
+
const s = store();
|
|
43
|
+
const prev = s.bySource.get(source) ?? { cost: 0, tokens: 0 };
|
|
44
|
+
s.bySource.set(source, { cost: prev.cost + cost, tokens: prev.tokens + tokens });
|
|
45
|
+
for (const listener of s.listeners) {
|
|
46
|
+
try {
|
|
47
|
+
listener();
|
|
48
|
+
} catch {
|
|
49
|
+
// A footer that cannot redraw is not the child's problem.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Everything the children have spent this session, and which packages spent it. */
|
|
55
|
+
export function childSpendTotal(): ChildSpend & { sources: string[] } {
|
|
56
|
+
const s = store();
|
|
57
|
+
let cost = 0;
|
|
58
|
+
let tokens = 0;
|
|
59
|
+
for (const spend of s.bySource.values()) {
|
|
60
|
+
cost += spend.cost;
|
|
61
|
+
tokens += spend.tokens;
|
|
62
|
+
}
|
|
63
|
+
return { cost, tokens, sources: [...s.bySource.keys()].sort() };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Start a new tally — a new session's children, not the last one's. */
|
|
67
|
+
export function resetChildSpend(): void {
|
|
68
|
+
store().bySource.clear();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Be told after every addition; returns the unsubscribe. */
|
|
72
|
+
export function onChildSpend(listener: () => void): () => void {
|
|
73
|
+
const s = store();
|
|
74
|
+
s.listeners.add(listener);
|
|
75
|
+
return () => {
|
|
76
|
+
s.listeners.delete(listener);
|
|
77
|
+
};
|
|
78
|
+
}
|
package/src/format.ts
CHANGED
|
@@ -14,9 +14,20 @@ export function formatCost(n: number): string {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/** Footer text: short, live. Null clears the indicator. */
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
/** Spend of child agent sessions (subagent/swarm/workflow), which is not in the session totals. */
|
|
18
|
+
export interface ChildSpendLike {
|
|
19
|
+
cost: number;
|
|
20
|
+
tokens: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function hasChildSpend(children: ChildSpendLike | undefined): children is ChildSpendLike {
|
|
24
|
+
return !!children && (children.cost > 0 || children.tokens > 0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function footerText(session: UsageTotals, children?: ChildSpendLike): string | undefined {
|
|
28
|
+
const agents = hasChildSpend(children) ? ` · agents ${formatCost(children.cost)}` : "";
|
|
29
|
+
if (session.messages === 0) return agents ? `📊 0 tok · $0${agents}` : undefined;
|
|
30
|
+
return `📊 ${formatTokens(session.totalTokens)} tok · ${formatCost(session.cost)}${agents}`;
|
|
20
31
|
}
|
|
21
32
|
|
|
22
33
|
const GAUGE_CELLS = 6;
|
|
@@ -38,12 +49,17 @@ export function contextGauge(pct: number | null): string {
|
|
|
38
49
|
return `${clamped >= 90 ? "⚠ " : ""}ctx ${bar} ${Math.round(clamped)}%`;
|
|
39
50
|
}
|
|
40
51
|
|
|
41
|
-
export function sessionBlock(session: UsageTotals, contextPct: number | null): string {
|
|
52
|
+
export function sessionBlock(session: UsageTotals, contextPct: number | null, children?: ChildSpendLike): string {
|
|
42
53
|
const lines = [
|
|
43
54
|
"Session",
|
|
44
55
|
` tokens in ${formatTokens(session.input)} · out ${formatTokens(session.output)} · cache ${formatTokens(session.cacheRead)} read / ${formatTokens(session.cacheWrite)} write`,
|
|
45
56
|
` cost ${formatCost(session.cost)} (${session.messages} responses)`,
|
|
46
57
|
];
|
|
58
|
+
if (hasChildSpend(children)) {
|
|
59
|
+
// Child sessions of subagent/swarm/workflow run outside this branch; their
|
|
60
|
+
// spend is real and is NOT in the two lines above.
|
|
61
|
+
lines.push(` agents ${formatCost(children.cost)} · ${formatTokens(children.tokens)} tok in child sessions, on top of the above`);
|
|
62
|
+
}
|
|
47
63
|
if (contextPct !== null) {
|
|
48
64
|
lines.push(` context ~${Math.round(contextPct)}% of the window`);
|
|
49
65
|
}
|