@cr1ms0n/pi-subagent 0.8.9 → 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/src/usage.ts CHANGED
@@ -1,274 +1,316 @@
1
- import type { Message, Usage } from "@earendil-works/pi-ai";
2
- import type { RunSnapshot, UsageStats } from "./types.js";
3
- import { emptyUsage } from "./types.js";
4
- import { RUN_ENTRY_TYPE } from "./persistence.js";
5
-
6
- export interface UsageLedger {
7
- root: UsageStats;
8
- subagents: UsageStats;
9
- combined: UsageStats;
10
- runCount: number;
11
- taskCount: number;
12
- }
13
-
14
- const finite = (value: unknown): number =>
15
- typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
16
-
17
- export function normalizeUsage(value: Partial<UsageStats> | undefined): UsageStats {
18
- return {
19
- input: finite(value?.input),
20
- output: finite(value?.output),
21
- cacheRead: finite(value?.cacheRead),
22
- cacheWrite: finite(value?.cacheWrite),
23
- reasoning: finite(value?.reasoning),
24
- cost: finite(value?.cost),
25
- costInput: finite(value?.costInput),
26
- costOutput: finite(value?.costOutput),
27
- costCacheRead: finite(value?.costCacheRead),
28
- costCacheWrite: finite(value?.costCacheWrite),
29
- contextTokens: finite(value?.contextTokens),
30
- turns: finite(value?.turns),
31
- };
32
- }
33
-
34
- export function addUsage(...values: Array<Partial<UsageStats> | undefined>): UsageStats {
35
- return values.reduce<UsageStats>((sum, value) => {
36
- const next = normalizeUsage(value);
37
- sum.input += next.input;
38
- sum.output += next.output;
39
- sum.cacheRead += next.cacheRead;
40
- sum.cacheWrite += next.cacheWrite;
41
- sum.reasoning = (sum.reasoning ?? 0) + (next.reasoning ?? 0);
42
- sum.cost += next.cost;
43
- sum.costInput = (sum.costInput ?? 0) + (next.costInput ?? 0);
44
- sum.costOutput = (sum.costOutput ?? 0) + (next.costOutput ?? 0);
45
- sum.costCacheRead = (sum.costCacheRead ?? 0) + (next.costCacheRead ?? 0);
46
- sum.costCacheWrite = (sum.costCacheWrite ?? 0) + (next.costCacheWrite ?? 0);
47
- // Context size is a point-in-time measurement, not additive billed usage.
48
- if (next.contextTokens > 0) sum.contextTokens = next.contextTokens;
49
- sum.turns += next.turns;
50
- return sum;
51
- }, emptyUsage());
52
- }
53
-
54
- /**
55
- * Untrusted provider usage payload from a child's event stream. Old Pi builds
56
- * reported `cost` as a bare number; new ones report a category object.
57
- */
58
- interface ProviderUsageLike {
59
- input?: unknown;
60
- output?: unknown;
61
- cacheRead?: unknown;
62
- cacheWrite?: unknown;
63
- reasoning?: unknown;
64
- totalTokens?: unknown;
65
- cost?: number | { input?: unknown; output?: unknown; cacheRead?: unknown; cacheWrite?: unknown; total?: unknown };
66
- }
67
-
68
- /** Shared normalization of Pi's provider `Usage` payload into our aggregate stats. */
69
- function statsFromProviderUsage(usage: ProviderUsageLike, turns: number, contextTokens: unknown): UsageStats {
70
- const cost = typeof usage.cost === "object" && usage.cost !== null ? usage.cost : undefined;
71
- const categoryTotal = [cost?.input, cost?.output, cost?.cacheRead, cost?.cacheWrite]
72
- .reduce((sum: number, value: unknown) => sum + finite(value), 0);
73
- const reportedTotal = typeof cost?.total === "number" && Number.isFinite(cost.total) && cost.total >= 0
74
- ? cost.total
75
- : typeof usage.cost === "number" && Number.isFinite(usage.cost) && usage.cost >= 0
76
- ? usage.cost
77
- : categoryTotal;
78
- return normalizeUsage({
79
- input: finite(usage.input),
80
- output: finite(usage.output),
81
- cacheRead: finite(usage.cacheRead),
82
- cacheWrite: finite(usage.cacheWrite),
83
- reasoning: finite(usage.reasoning),
84
- cost: reportedTotal,
85
- costInput: finite(cost?.input),
86
- costOutput: finite(cost?.output),
87
- costCacheRead: finite(cost?.cacheRead),
88
- costCacheWrite: finite(cost?.cacheWrite),
89
- contextTokens: finite(contextTokens),
90
- turns,
91
- });
92
- }
93
-
94
- /** Message-shaped value with an optional usage payload (untrusted stream data). */
95
- type MessageWithUsage = { role?: unknown; usage?: ProviderUsageLike };
96
-
97
- export function usageFromMessage(message: Message | unknown): UsageStats {
98
- const msg = message as MessageWithUsage; // structural read of untrusted stream JSON; every field re-validated
99
- if (msg?.role !== "assistant" || !msg.usage) return emptyUsage();
100
- return statsFromProviderUsage(msg.usage, 1, msg.usage.totalTokens);
101
- }
102
-
103
- /**
104
- * Nested LLM usage reported on a toolResult message (Pi ≥ #6671, e.g. a
105
- * grandchild subagent's spend). Not a turn; not a context measurement.
106
- */
107
- export function usageFromToolResultMessage(message: Message | unknown): UsageStats {
108
- const msg = message as MessageWithUsage; // structural read of untrusted stream JSON; every field re-validated
109
- if (msg?.role !== "toolResult" || !msg.usage) return emptyUsage();
110
- return statsFromProviderUsage(msg.usage, 0, 0);
111
- }
112
-
113
- /** True when the stats represent any billed work (tokens or cost). */
114
- export function hasBilledUsage(stats: Partial<UsageStats> | undefined): boolean {
115
- const n = normalizeUsage(stats);
116
- return n.cost > 0 || n.input + n.output + n.cacheRead + n.cacheWrite > 0;
117
- }
118
-
119
- /**
120
- * Convert aggregate stats into Pi's native `Usage` shape for tool-result
121
- * accounting (AgentToolResult.usage / tool_result hook). Pi folds the four
122
- * token categories plus `cost.total` into footer, /session, and RPC totals.
123
- */
124
- export function toPiUsage(stats: UsageStats): Usage {
125
- const n = normalizeUsage(stats);
126
- return {
127
- input: n.input,
128
- output: n.output,
129
- cacheRead: n.cacheRead,
130
- cacheWrite: n.cacheWrite,
131
- ...(n.reasoning ? { reasoning: n.reasoning } : {}),
132
- totalTokens: n.input + n.output + n.cacheRead + n.cacheWrite,
133
- cost: {
134
- input: n.costInput ?? 0,
135
- output: n.costOutput ?? 0,
136
- cacheRead: n.costCacheRead ?? 0,
137
- cacheWrite: n.costCacheWrite ?? 0,
138
- total: n.cost,
139
- },
140
- };
141
- }
142
-
143
- /** Root usage from active-branch session message entries only. */
144
- export function rootUsageFromEntries(entries: readonly unknown[]): UsageStats {
145
- const seenMessageEntries = new Set<string>();
146
- const usages: UsageStats[] = [];
147
- for (const raw of entries) {
148
- const entry = raw as { type?: string; id?: string; message?: Message };
149
- if (entry.type !== "message" || !entry.message || entry.message.role !== "assistant") continue;
150
- if (entry.id && seenMessageEntries.has(entry.id)) continue;
151
- if (entry.id) seenMessageEntries.add(entry.id);
152
- usages.push(usageFromMessage(entry.message));
153
- }
154
- return addUsage(...usages);
155
- }
156
-
157
- /** Count each terminal snapshot once by full run ID. */
158
- export function subagentUsageFromSnapshots(snapshots: Iterable<RunSnapshot>): {
159
- usage: UsageStats;
160
- runCount: number;
161
- taskCount: number;
162
- } {
163
- const byId = new Map<string, RunSnapshot>();
164
- for (const snapshot of snapshots) byId.set(snapshot.id, snapshot);
165
- const runs = [...byId.values()];
166
- const taskUsages = runs.flatMap((run) => run.results.map((result) => result.usage));
167
- return { usage: addUsage(...taskUsages), runCount: runs.length, taskCount: taskUsages.length };
168
- }
169
-
170
- /**
171
- * Reconstruct cost directly from terminal persistence events on the active
172
- * branch. This remains complete even when old UI snapshots are evicted.
173
- */
174
- interface RunUsage {
175
- usage: UsageStats;
176
- taskCount: number;
177
- }
178
-
179
- export function subagentUsageFromEntries(entries: readonly unknown[]): {
180
- usage: UsageStats;
181
- runCount: number;
182
- taskCount: number;
183
- runIds: Set<string>;
184
- byRun: Map<string, RunUsage>;
185
- } {
186
- const latestById = new Map<string, { results: any[] }>();
187
- for (const raw of entries) {
188
- const entry = raw as { type?: string; customType?: string; data?: any };
189
- if (entry.type !== "custom" || entry.customType !== RUN_ENTRY_TYPE) continue;
190
- const event = entry.data;
191
- if (!event || event.schemaVersion !== 1 || typeof event.id !== "string") continue;
192
- // Start/checkpoint/terminal records carry cumulative task usage. Session
193
- // branch order is canonical, so the latest results replace earlier values.
194
- if (["start", "checkpoint", "terminal"].includes(event.type) && Array.isArray(event.data?.results)) {
195
- latestById.set(event.id, { results: event.data.results });
196
- }
197
- }
198
- const byRun = new Map<string, RunUsage>();
199
- for (const [id, run] of latestById) {
200
- const usages = run.results.map((result) => result.usage);
201
- byRun.set(id, { usage: addUsage(...usages), taskCount: usages.length });
202
- }
203
- return {
204
- usage: addUsage(...[...byRun.values()].map((run) => run.usage)),
205
- runCount: byRun.size,
206
- taskCount: [...byRun.values()].reduce((sum, run) => sum + run.taskCount, 0),
207
- runIds: new Set(byRun.keys()),
208
- byRun,
209
- };
210
- }
211
-
212
- function messageFingerprint(message: any): string {
213
- return JSON.stringify([
214
- message?.timestamp ?? null,
215
- message?.provider ?? null,
216
- message?.model ?? null,
217
- message?.responseId ?? null,
218
- message?.usage?.input ?? null,
219
- message?.usage?.output ?? null,
220
- message?.usage?.cacheRead ?? null,
221
- message?.usage?.cacheWrite ?? null,
222
- message?.usage?.cost?.total ?? null,
223
- ]);
224
- }
225
-
226
- export function buildUsageLedger(
227
- activeBranchEntries: readonly unknown[],
228
- currentSnapshots: Iterable<RunSnapshot> = [],
229
- pendingRootMessages: readonly Message[] = [],
230
- ): UsageLedger {
231
- const branchRoot = rootUsageFromEntries(activeBranchEntries);
232
- const branchFingerprintCounts = new Map<string, number>();
233
- for (const raw of activeBranchEntries as any[]) {
234
- if (raw?.type !== "message" || raw.message?.role !== "assistant") continue;
235
- const fingerprint = messageFingerprint(raw.message);
236
- branchFingerprintCounts.set(fingerprint, (branchFingerprintCounts.get(fingerprint) ?? 0) + 1);
237
- }
238
- const pending = pendingRootMessages.filter((message) => {
239
- const fingerprint = messageFingerprint(message);
240
- const remaining = branchFingerprintCounts.get(fingerprint) ?? 0;
241
- if (remaining === 0) return true;
242
- branchFingerprintCounts.set(fingerprint, remaining - 1);
243
- return false;
244
- });
245
- const root = addUsage(branchRoot, ...pending.map(usageFromMessage));
246
- const persisted = subagentUsageFromEntries(activeBranchEntries);
247
- // Current in-memory snapshots are newer than SessionManager visibility. They
248
- // replace the same run's persisted checkpoint, while unrelated runs remain.
249
- const currentById = new Map<string, RunSnapshot>();
250
- for (const snapshot of currentSnapshots) currentById.set(snapshot.id, snapshot);
251
- const persistedOnly = [...persisted.byRun.entries()].filter(([id]) => !currentById.has(id));
252
- const current = subagentUsageFromSnapshots(currentById.values());
253
- const subagents = addUsage(
254
- ...persistedOnly.map(([, run]) => run.usage),
255
- current.usage,
256
- );
257
- return {
258
- root,
259
- subagents,
260
- combined: addUsage(root, subagents),
261
- runCount: persistedOnly.length + current.runCount,
262
- taskCount: persistedOnly.reduce((sum, [, run]) => sum + run.taskCount, 0) + current.taskCount,
263
- };
264
- }
265
-
266
- export function formatLedger(ledger: UsageLedger): string {
267
- const money = (n: number) => `$${n.toFixed(4)}`;
268
- const tokens = (u: UsageStats) => `${u.input + u.output} tok`;
269
- return [
270
- `root ${money(ledger.root.cost)} (${tokens(ledger.root)})`,
271
- `subagents ${money(ledger.subagents.cost)} (${tokens(ledger.subagents)}, ${ledger.runCount} runs/${ledger.taskCount} tasks)`,
272
- `combined ${money(ledger.combined.cost)} (${tokens(ledger.combined)})`,
273
- ].join(" · ");
274
- }
1
+ import type { Message, Usage } from "@earendil-works/pi-ai";
2
+ import type { RoutingReceipt } from "./routing-types.js";
3
+ import type { RunSnapshot, UsageStats } from "./types.js";
4
+ import { emptyUsage } from "./types.js";
5
+ import { foldRoutingReceipts, RUN_ENTRY_TYPE } from "./persistence.js";
6
+
7
+ export interface UsageLedger {
8
+ root: UsageStats;
9
+ subagents: UsageStats;
10
+ /** Reported selector tokens only; `cost` is the numeric placeholder 0 (currency unreported). */
11
+ routing: UsageStats;
12
+ combined: UsageStats;
13
+ runCount: number;
14
+ taskCount: number;
15
+ /** Number of distinct routing receipts counted once on the active branch. */
16
+ routingRequests: number;
17
+ /** Receipts whose selector usage was not validly reported (honest unknown, not zero-spend). */
18
+ routingUnknown: number;
19
+ /** TypeSafe reports tokens, never billed currency; numeric USD totals exclude this. */
20
+ routingCurrency: "unreported";
21
+ }
22
+
23
+ const finite = (value: unknown): number =>
24
+ typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
25
+
26
+ export function normalizeUsage(value: Partial<UsageStats> | undefined): UsageStats {
27
+ return {
28
+ input: finite(value?.input),
29
+ output: finite(value?.output),
30
+ cacheRead: finite(value?.cacheRead),
31
+ cacheWrite: finite(value?.cacheWrite),
32
+ reasoning: finite(value?.reasoning),
33
+ cost: finite(value?.cost),
34
+ costInput: finite(value?.costInput),
35
+ costOutput: finite(value?.costOutput),
36
+ costCacheRead: finite(value?.costCacheRead),
37
+ costCacheWrite: finite(value?.costCacheWrite),
38
+ contextTokens: finite(value?.contextTokens),
39
+ turns: finite(value?.turns),
40
+ };
41
+ }
42
+
43
+ export function addUsage(...values: Array<Partial<UsageStats> | undefined>): UsageStats {
44
+ return values.reduce<UsageStats>((sum, value) => {
45
+ const next = normalizeUsage(value);
46
+ sum.input += next.input;
47
+ sum.output += next.output;
48
+ sum.cacheRead += next.cacheRead;
49
+ sum.cacheWrite += next.cacheWrite;
50
+ sum.reasoning = (sum.reasoning ?? 0) + (next.reasoning ?? 0);
51
+ sum.cost += next.cost;
52
+ sum.costInput = (sum.costInput ?? 0) + (next.costInput ?? 0);
53
+ sum.costOutput = (sum.costOutput ?? 0) + (next.costOutput ?? 0);
54
+ sum.costCacheRead = (sum.costCacheRead ?? 0) + (next.costCacheRead ?? 0);
55
+ sum.costCacheWrite = (sum.costCacheWrite ?? 0) + (next.costCacheWrite ?? 0);
56
+ // Context size is a point-in-time measurement, not additive billed usage.
57
+ if (next.contextTokens > 0) sum.contextTokens = next.contextTokens;
58
+ sum.turns += next.turns;
59
+ return sum;
60
+ }, emptyUsage());
61
+ }
62
+
63
+ /**
64
+ * Untrusted provider usage payload from a child's event stream. Old Pi builds
65
+ * reported `cost` as a bare number; new ones report a category object.
66
+ */
67
+ interface ProviderUsageLike {
68
+ input?: unknown;
69
+ output?: unknown;
70
+ cacheRead?: unknown;
71
+ cacheWrite?: unknown;
72
+ reasoning?: unknown;
73
+ totalTokens?: unknown;
74
+ cost?: number | { input?: unknown; output?: unknown; cacheRead?: unknown; cacheWrite?: unknown; total?: unknown };
75
+ }
76
+
77
+ /** Shared normalization of Pi's provider `Usage` payload into our aggregate stats. */
78
+ function statsFromProviderUsage(usage: ProviderUsageLike, turns: number, contextTokens: unknown): UsageStats {
79
+ const cost = typeof usage.cost === "object" && usage.cost !== null ? usage.cost : undefined;
80
+ const categoryTotal = [cost?.input, cost?.output, cost?.cacheRead, cost?.cacheWrite]
81
+ .reduce((sum: number, value: unknown) => sum + finite(value), 0);
82
+ const reportedTotal = typeof cost?.total === "number" && Number.isFinite(cost.total) && cost.total >= 0
83
+ ? cost.total
84
+ : typeof usage.cost === "number" && Number.isFinite(usage.cost) && usage.cost >= 0
85
+ ? usage.cost
86
+ : categoryTotal;
87
+ return normalizeUsage({
88
+ input: finite(usage.input),
89
+ output: finite(usage.output),
90
+ cacheRead: finite(usage.cacheRead),
91
+ cacheWrite: finite(usage.cacheWrite),
92
+ reasoning: finite(usage.reasoning),
93
+ cost: reportedTotal,
94
+ costInput: finite(cost?.input),
95
+ costOutput: finite(cost?.output),
96
+ costCacheRead: finite(cost?.cacheRead),
97
+ costCacheWrite: finite(cost?.cacheWrite),
98
+ contextTokens: finite(contextTokens),
99
+ turns,
100
+ });
101
+ }
102
+
103
+ /** Message-shaped value with an optional usage payload (untrusted stream data). */
104
+ type MessageWithUsage = { role?: unknown; usage?: ProviderUsageLike };
105
+
106
+ export function usageFromMessage(message: Message | unknown): UsageStats {
107
+ const msg = message as MessageWithUsage; // structural read of untrusted stream JSON; every field re-validated
108
+ if (msg?.role !== "assistant" || !msg.usage) return emptyUsage();
109
+ return statsFromProviderUsage(msg.usage, 1, msg.usage.totalTokens);
110
+ }
111
+
112
+ /**
113
+ * Nested LLM usage reported on a toolResult message (Pi #6671, e.g. a
114
+ * grandchild subagent's spend). Not a turn; not a context measurement.
115
+ */
116
+ export function usageFromToolResultMessage(message: Message | unknown): UsageStats {
117
+ const msg = message as MessageWithUsage; // structural read of untrusted stream JSON; every field re-validated
118
+ if (msg?.role !== "toolResult" || !msg.usage) return emptyUsage();
119
+ return statsFromProviderUsage(msg.usage, 0, 0);
120
+ }
121
+
122
+ /** True when the stats represent any billed work (tokens or cost). */
123
+ export function hasBilledUsage(stats: Partial<UsageStats> | undefined): boolean {
124
+ const n = normalizeUsage(stats);
125
+ return n.cost > 0 || n.input + n.output + n.cacheRead + n.cacheWrite > 0;
126
+ }
127
+
128
+ /**
129
+ * Convert aggregate stats into Pi's native `Usage` shape for tool-result
130
+ * accounting (AgentToolResult.usage / tool_result hook). Pi folds the four
131
+ * token categories plus `cost.total` into footer, /session, and RPC totals.
132
+ */
133
+ export function toPiUsage(stats: UsageStats): Usage {
134
+ const n = normalizeUsage(stats);
135
+ return {
136
+ input: n.input,
137
+ output: n.output,
138
+ cacheRead: n.cacheRead,
139
+ cacheWrite: n.cacheWrite,
140
+ ...(n.reasoning ? { reasoning: n.reasoning } : {}),
141
+ totalTokens: n.input + n.output + n.cacheRead + n.cacheWrite,
142
+ cost: {
143
+ input: n.costInput ?? 0,
144
+ output: n.costOutput ?? 0,
145
+ cacheRead: n.costCacheRead ?? 0,
146
+ cacheWrite: n.costCacheWrite ?? 0,
147
+ total: n.cost,
148
+ },
149
+ };
150
+ }
151
+
152
+ /** Root usage from active-branch session message entries only. */
153
+ export function rootUsageFromEntries(entries: readonly unknown[]): UsageStats {
154
+ const seenMessageEntries = new Set<string>();
155
+ const usages: UsageStats[] = [];
156
+ for (const raw of entries) {
157
+ const entry = raw as { type?: string; id?: string; message?: Message };
158
+ if (entry.type !== "message" || !entry.message || entry.message.role !== "assistant") continue;
159
+ if (entry.id && seenMessageEntries.has(entry.id)) continue;
160
+ if (entry.id) seenMessageEntries.add(entry.id);
161
+ usages.push(usageFromMessage(entry.message));
162
+ }
163
+ return addUsage(...usages);
164
+ }
165
+
166
+ /** Count each terminal snapshot once by full run ID. */
167
+ export function subagentUsageFromSnapshots(snapshots: Iterable<RunSnapshot>): {
168
+ usage: UsageStats;
169
+ runCount: number;
170
+ taskCount: number;
171
+ } {
172
+ const byId = new Map<string, RunSnapshot>();
173
+ for (const snapshot of snapshots) byId.set(snapshot.id, snapshot);
174
+ const runs = [...byId.values()];
175
+ const taskUsages = runs.flatMap((run) => run.results.map((result) => result.usage));
176
+ return { usage: addUsage(...taskUsages), runCount: runs.length, taskCount: taskUsages.length };
177
+ }
178
+
179
+ /**
180
+ * Selector usage as a separate category. Counts validated reported token fields;
181
+ * `cost` stays the honest numeric placeholder 0 because TypeSafe never reports billed
182
+ * currency. Partial reports retain known tokens while their completeness stays unknown.
183
+ */
184
+ export function routingUsage(receipts: readonly RoutingReceipt[]): UsageStats {
185
+ let input = 0;
186
+ let output = 0;
187
+ for (const receipt of receipts) {
188
+ if (!receipt) continue;
189
+ input += finite(receipt.inputTokens);
190
+ output += finite(receipt.outputTokens);
191
+ }
192
+ return normalizeUsage({ input, output });
193
+ }
194
+
195
+ /**
196
+ * Reconstruct cost directly from terminal persistence events on the active
197
+ * branch. This remains complete even when old UI snapshots are evicted.
198
+ */
199
+ interface RunUsage {
200
+ usage: UsageStats;
201
+ taskCount: number;
202
+ }
203
+
204
+ export function subagentUsageFromEntries(entries: readonly unknown[]): {
205
+ usage: UsageStats;
206
+ runCount: number;
207
+ taskCount: number;
208
+ runIds: Set<string>;
209
+ byRun: Map<string, RunUsage>;
210
+ } {
211
+ const latestById = new Map<string, { results: any[] }>();
212
+ for (const raw of entries) {
213
+ const entry = raw as { type?: string; customType?: string; data?: any };
214
+ if (entry.type !== "custom" || entry.customType !== RUN_ENTRY_TYPE) continue;
215
+ const event = entry.data;
216
+ if (!event || event.schemaVersion !== 1 || typeof event.id !== "string") continue;
217
+ // Start/checkpoint/terminal records carry cumulative task usage. Session
218
+ // branch order is canonical, so the latest results replace earlier values.
219
+ if (["start", "checkpoint", "terminal"].includes(event.type) && Array.isArray(event.data?.results)) {
220
+ latestById.set(event.id, { results: event.data.results });
221
+ }
222
+ }
223
+ const byRun = new Map<string, RunUsage>();
224
+ for (const [id, run] of latestById) {
225
+ const usages = run.results.map((result) => result.usage);
226
+ byRun.set(id, { usage: addUsage(...usages), taskCount: usages.length });
227
+ }
228
+ return {
229
+ usage: addUsage(...[...byRun.values()].map((run) => run.usage)),
230
+ runCount: byRun.size,
231
+ taskCount: [...byRun.values()].reduce((sum, run) => sum + run.taskCount, 0),
232
+ runIds: new Set(byRun.keys()),
233
+ byRun,
234
+ };
235
+ }
236
+
237
+ function messageFingerprint(message: any): string {
238
+ return JSON.stringify([
239
+ message?.timestamp ?? null,
240
+ message?.provider ?? null,
241
+ message?.model ?? null,
242
+ message?.responseId ?? null,
243
+ message?.usage?.input ?? null,
244
+ message?.usage?.output ?? null,
245
+ message?.usage?.cacheRead ?? null,
246
+ message?.usage?.cacheWrite ?? null,
247
+ message?.usage?.cost?.total ?? null,
248
+ ]);
249
+ }
250
+
251
+ export function buildUsageLedger(
252
+ activeBranchEntries: readonly unknown[],
253
+ currentSnapshots: Iterable<RunSnapshot> = [],
254
+ pendingRootMessages: readonly Message[] = [],
255
+ pendingRoutingEvents: readonly unknown[] = [],
256
+ ): UsageLedger {
257
+ const branchRoot = rootUsageFromEntries(activeBranchEntries);
258
+ const branchFingerprintCounts = new Map<string, number>();
259
+ for (const raw of activeBranchEntries as any[]) {
260
+ if (raw?.type !== "message" || raw.message?.role !== "assistant") continue;
261
+ const fingerprint = messageFingerprint(raw.message);
262
+ branchFingerprintCounts.set(fingerprint, (branchFingerprintCounts.get(fingerprint) ?? 0) + 1);
263
+ }
264
+ const pending = pendingRootMessages.filter((message) => {
265
+ const fingerprint = messageFingerprint(message);
266
+ const remaining = branchFingerprintCounts.get(fingerprint) ?? 0;
267
+ if (remaining === 0) return true;
268
+ branchFingerprintCounts.set(fingerprint, remaining - 1);
269
+ return false;
270
+ });
271
+ const root = addUsage(branchRoot, ...pending.map(usageFromMessage));
272
+ const persisted = subagentUsageFromEntries(activeBranchEntries);
273
+ // Current in-memory snapshots are newer than SessionManager visibility. They
274
+ // replace the same run's persisted checkpoint, while unrelated runs remain.
275
+ const currentById = new Map<string, RunSnapshot>();
276
+ for (const snapshot of currentSnapshots) currentById.set(snapshot.id, snapshot);
277
+ const persistedOnly = [...persisted.byRun.entries()].filter(([id]) => !currentById.has(id));
278
+ const current = subagentUsageFromSnapshots(currentById.values());
279
+ const subagents = addUsage(
280
+ ...persistedOnly.map(([, run]) => run.usage),
281
+ current.usage,
282
+ );
283
+ // Fold routing receipts once by full request ID. Child native usage already carries
284
+ // any descendant routing that a grandchild reported, so only this branch's own
285
+ // selector events are added; route references inside results are never re-added.
286
+ const foldedRouting = foldRoutingReceipts(activeBranchEntries, pendingRoutingEvents);
287
+ const routingReceipts = [...foldedRouting.values()].map((entry) => entry.receipt);
288
+ const routing = routingUsage(routingReceipts);
289
+ return {
290
+ root,
291
+ subagents,
292
+ routing,
293
+ combined: addUsage(root, subagents, routing),
294
+ runCount: persistedOnly.length + current.runCount,
295
+ taskCount: persistedOnly.reduce((sum, [, run]) => sum + run.taskCount, 0) + current.taskCount,
296
+ routingRequests: routingReceipts.length,
297
+ routingUnknown: routingReceipts.filter((receipt) => receipt.usageStatus !== "reported").length,
298
+ routingCurrency: "unreported",
299
+ };
300
+ }
301
+
302
+ export function formatLedger(ledger: UsageLedger): string {
303
+ const money = (n: number) => `$${n.toFixed(4)}`;
304
+ const tokens = (u: UsageStats) => `${u.input + u.output} tok`;
305
+ const parts = [
306
+ `root ${money(ledger.root.cost)} (${tokens(ledger.root)})`,
307
+ `subagents ${money(ledger.subagents.cost)} (${tokens(ledger.subagents)}, ${ledger.runCount} runs/${ledger.taskCount} tasks)`,
308
+ ];
309
+ if (ledger.routingRequests > 0) {
310
+ const unknown = ledger.routingUnknown > 0 ? `, ${ledger.routingUnknown} unknown $` : "";
311
+ parts.push(`routing ${tokens(ledger.routing)} (${ledger.routingRequests} req${unknown}; $ unreported)`);
312
+ }
313
+ const moneyNote = ledger.routingRequests > 0 ? "; USD excl. unreported routing" : "";
314
+ parts.push(`combined ${money(ledger.combined.cost)} (${tokens(ledger.combined)}${moneyNote})`);
315
+ return parts.join(" · ");
316
+ }