pi-exchange-stats 0.1.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/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ezoushen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE.
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # pi-exchange-stats
2
+
3
+ Show timing, token, cache, cost, and tool-use statistics for each Pi exchange.
4
+
5
+ An **exchange** is one uninterrupted work span from a submitted prompt until Pi has
6
+ nothing left to do automatically. A **turn** is one model response plus the tools it
7
+ invokes, so an exchange can contain several turns. The status line shows the current
8
+ or most recent exchange, and a transcript card records the settled breakdown.
9
+
10
+ Tool time is the union of tool spans, not their sum, so parallel calls are not counted
11
+ twice. Model time is estimated as turn wall time minus tool time. Output throughput is
12
+ calculated per turn, while cumulative cost and token fields use Pi's reported usage.
13
+ Run `/exstats` to append a cumulative session card.
14
+
15
+ ## External contract
16
+
17
+ Pi must emit its documented session, agent, turn, tool, and UI-prompt lifecycle events.
18
+ Assistant messages should include usage and cost fields when the provider supports
19
+ them. No network service or machine-local file is required.
20
+
21
+ ## If the contract is unmet
22
+
23
+ Missing usage fields are reported as zero; the extension does not invent token or cost
24
+ data. Missing lifecycle events produce an incomplete or absent span. When UI status is
25
+ unavailable, status updates are skipped. If custom-entry persistence is unavailable,
26
+ the live status can still update and the agent turn continues.
27
+
28
+ The measurements are process-local. They do not claim provider-side queue time,
29
+ exclusive model compute time, or billing beyond the usage object Pi received.
30
+
31
+ ## Settings
32
+
33
+ There are no settings and no environment variables. The package operates entirely
34
+ from Pi-provided events and context.
35
+
36
+ ## Install and verify
37
+
38
+ ```sh
39
+ pi install npm:pi-exchange-stats
40
+ ```
41
+
42
+ Submit a prompt that makes at least one tool call and wait for Pi to settle. Expand the
43
+ exchange card and verify that its turn count, output tokens, tool names, and wall-time
44
+ breakdown match the transcript. Run `/exstats` and confirm that the session card equals
45
+ the sum of completed exchanges. To check parallel-tool accounting, run two overlapping
46
+ tools and confirm their union is not larger than the exchange wall time.
@@ -0,0 +1,382 @@
1
+ // extensions/exchange-stats/exchange-stats.ts
2
+ import { keyHint } from "@earendil-works/pi-coding-agent";
3
+ import { Box, Text } from "@earendil-works/pi-tui";
4
+ var ENTRY_TYPE = "exchange-stats";
5
+ var STATUS_KEY = "exchange";
6
+ var TICK_MS = 1e3;
7
+ var TOOL_SHARE_NOTE = 0.25;
8
+ var emptyTotals = () => ({
9
+ input: 0,
10
+ output: 0,
11
+ reasoning: 0,
12
+ cacheRead: 0,
13
+ cacheWrite: 0,
14
+ totalTokens: 0,
15
+ cost: 0
16
+ });
17
+ function fmtDuration(ms) {
18
+ if (!Number.isFinite(ms) || ms < 0) return "\u2014";
19
+ if (ms < 1e3) return `${Math.round(ms)}ms`;
20
+ const s = ms / 1e3;
21
+ if (s < 60) return `${s.toFixed(1)}s`;
22
+ const m = Math.floor(s / 60);
23
+ const rem = Math.round(s % 60);
24
+ return rem > 0 ? `${m}m${rem}s` : `${m}m`;
25
+ }
26
+ function fmtTokens(n) {
27
+ if (!Number.isFinite(n) || n <= 0) return "0";
28
+ if (n < 1e3) return `${Math.round(n)}`;
29
+ if (n < 1e6) return `${(n / 1e3).toFixed(1)}k`;
30
+ return `${(n / 1e6).toFixed(2)}M`;
31
+ }
32
+ function fmtCost(cost) {
33
+ if (!Number.isFinite(cost) || cost <= 0) return "$0";
34
+ return cost < 0.01 ? `$${cost.toFixed(5)}` : `$${cost.toFixed(4)}`;
35
+ }
36
+ function fmtRate(tokensPerSec) {
37
+ if (!Number.isFinite(tokensPerSec) || tokensPerSec <= 0) return "\u2014";
38
+ return tokensPerSec < 1e3 ? `${Math.round(tokensPerSec)} tok/s` : `${(tokensPerSec / 1e3).toFixed(1)}k tok/s`;
39
+ }
40
+ function plural(n, one, many = `${one}s`) {
41
+ return n === 1 ? `${n} ${one}` : `${n} ${many}`;
42
+ }
43
+ function openToolText(spans, now) {
44
+ let longest;
45
+ for (const span of spans.values()) {
46
+ if (!longest || now - span.start > now - longest.start) longest = span;
47
+ }
48
+ if (!longest) return void 0;
49
+ const extra = spans.size > 1 ? ` (+${spans.size - 1})` : "";
50
+ return `${longest.name} ${fmtDuration(now - longest.start)}${extra}`;
51
+ }
52
+ function expandHint() {
53
+ try {
54
+ return ` (${keyHint("app.tools.expand", "to expand")})`;
55
+ } catch {
56
+ return " (expand for per-turn detail)";
57
+ }
58
+ }
59
+ function toolBreakdown(turn) {
60
+ return turn.tools.map((tool) => `${tool.name} ${fmtDuration(tool.ms)}${tool.isError ? " (failed)" : ""}`).join(", ");
61
+ }
62
+ function unionMs(runs) {
63
+ if (runs.length === 0) return 0;
64
+ const sorted = [...runs].sort((a, b) => a.start - b.start);
65
+ let total = 0;
66
+ let start = sorted[0].start;
67
+ let end = sorted[0].end;
68
+ for (const run of sorted.slice(1)) {
69
+ if (run.start > end) {
70
+ total += end - start;
71
+ start = run.start;
72
+ end = run.end;
73
+ } else if (run.end > end) {
74
+ end = run.end;
75
+ }
76
+ }
77
+ return total + (end - start);
78
+ }
79
+ function exchange_stats_default(pi) {
80
+ const sessionTotals = {
81
+ ...emptyTotals(),
82
+ exchanges: 0,
83
+ turnCount: 0,
84
+ durationMs: 0,
85
+ toolMs: 0,
86
+ waitingMs: 0
87
+ };
88
+ let sessionStartedAt = 0;
89
+ let running = false;
90
+ let startedAt = 0;
91
+ let promptCount = 0;
92
+ let exchangeIndex = 0;
93
+ let model = "unknown";
94
+ let stopReason = "stop";
95
+ let turns = [];
96
+ let totals = emptyTotals();
97
+ let waitingMs = 0;
98
+ let waitingStart;
99
+ let liveTimer;
100
+ let liveRefresh;
101
+ let activeTurn;
102
+ let activeToolRuns = [];
103
+ function setStatus(text, ctx) {
104
+ if (!ctx.hasUI) return;
105
+ ctx.ui.setStatus(STATUS_KEY, text);
106
+ }
107
+ function liveStatusText() {
108
+ const now = Date.now();
109
+ const parts = [`\u23F1 ${fmtDuration(now - startedAt)}`];
110
+ if (turns.length > 0) parts.push(`${plural(turns.length, "turn")} done`);
111
+ const toolText = activeTurn ? openToolText(activeTurn.spans, now) : void 0;
112
+ if (toolText) parts.push(toolText);
113
+ else if (activeTurn) parts.push(`turn ${activeTurn.index}`);
114
+ return parts.join(" \xB7 ");
115
+ }
116
+ function stopLiveTimer() {
117
+ if (liveTimer) clearInterval(liveTimer);
118
+ liveTimer = void 0;
119
+ liveRefresh = void 0;
120
+ }
121
+ function resetExchangeState() {
122
+ running = false;
123
+ startedAt = 0;
124
+ promptCount = 0;
125
+ turns = [];
126
+ totals = emptyTotals();
127
+ waitingMs = 0;
128
+ waitingStart = void 0;
129
+ activeTurn = void 0;
130
+ activeToolRuns = [];
131
+ stopLiveTimer();
132
+ }
133
+ pi.registerEntryRenderer(ENTRY_TYPE, (entry, { expanded }, theme) => {
134
+ const data = entry.data;
135
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
136
+ if (!data) {
137
+ box.addChild(new Text(theme.fg("dim", "(no stats)"), 0, 0));
138
+ return box;
139
+ }
140
+ const isSession = data.kind === "session";
141
+ const headline = isSession ? `\u{1F4CA} Session \xB7 ${plural(data.turnCount, "turn")} across ${plural(data.index, "exchange")}` : `\u23F1 Exchange ${data.index} \xB7 ${fmtDuration(data.durationMs)}`;
142
+ box.addChild(
143
+ new Text(
144
+ theme.fg("accent", theme.bold(headline)) + theme.fg("dim", ` ${data.model}`) + (!expanded && !isSession ? theme.fg("dim", expandHint()) : ""),
145
+ 0,
146
+ 0
147
+ )
148
+ );
149
+ const summary = [];
150
+ if (isSession) {
151
+ summary.push(`active ${fmtDuration(data.durationMs)}`);
152
+ } else {
153
+ summary.push(plural(data.turnCount, "turn"), plural(data.promptCount, "prompt"));
154
+ }
155
+ if (data.toolMs > 0) summary.push(`tools ${fmtDuration(data.toolMs)}`);
156
+ summary.push(`out ${fmtTokens(data.output)}`, fmtCost(data.cost));
157
+ if (data.waitingMs > 0) summary.push(`waiting ${fmtDuration(data.waitingMs)}`);
158
+ box.addChild(new Text(theme.fg("dim", summary.join(" \xB7 ")), 0, 0));
159
+ if (expanded) {
160
+ for (const turn of data.turns) {
161
+ const detail = [`model ${fmtDuration(turn.modelMs)}`];
162
+ if (turn.toolMs > 0) detail.push(`tools ${fmtDuration(turn.toolMs)} (${toolBreakdown(turn)})`);
163
+ detail.push(`out ${fmtTokens(turn.output)}`, fmtRate(turn.outputPerSec));
164
+ box.addChild(
165
+ new Text(
166
+ ` ${theme.fg("dim", `#${String(turn.index).padStart(2, " ")}`)} ` + theme.fg("text", fmtDuration(turn.durationMs).padStart(8, " ")) + theme.fg("dim", ` ${detail.join(" \xB7 ")}`),
167
+ 0,
168
+ 0
169
+ )
170
+ );
171
+ }
172
+ if (isSession && data.turnCount > 0) {
173
+ box.addChild(
174
+ new Text(
175
+ theme.fg("dim", `avg ${fmtDuration(data.durationMs / data.turnCount)} per turn`),
176
+ 0,
177
+ 0
178
+ )
179
+ );
180
+ }
181
+ const tokenParts = [
182
+ `in ${fmtTokens(data.input)}`,
183
+ `out ${fmtTokens(data.output)}`,
184
+ `cache r ${fmtTokens(data.cacheRead)} / w ${fmtTokens(data.cacheWrite)}`,
185
+ `total ${fmtTokens(data.totalTokens)}`,
186
+ fmtCost(data.cost)
187
+ ];
188
+ if (data.reasoning > 0) tokenParts.splice(2, 0, `thinking ${fmtTokens(data.reasoning)}`);
189
+ box.addChild(new Text(theme.fg("dim", tokenParts.join(" \xB7 ")), 0, 0));
190
+ if (data.startedAt > 0) {
191
+ const from = new Date(data.startedAt).toLocaleTimeString();
192
+ const to = new Date(data.endedAt).toLocaleTimeString();
193
+ box.addChild(new Text(theme.fg("dim", `${from} \u2192 ${to} stop: ${data.stopReason}`), 0, 0));
194
+ }
195
+ }
196
+ return box;
197
+ });
198
+ pi.on("session_start", (_event, ctx) => {
199
+ resetExchangeState();
200
+ sessionStartedAt = Date.now();
201
+ setStatus("\u23F1 ready", ctx);
202
+ });
203
+ pi.on("session_shutdown", () => {
204
+ resetExchangeState();
205
+ });
206
+ pi.on("before_agent_start", (_event, ctx) => {
207
+ if (running) {
208
+ promptCount++;
209
+ return;
210
+ }
211
+ running = true;
212
+ startedAt = Date.now();
213
+ promptCount = 1;
214
+ exchangeIndex++;
215
+ turns = [];
216
+ totals = emptyTotals();
217
+ waitingMs = 0;
218
+ waitingStart = void 0;
219
+ activeTurn = void 0;
220
+ activeToolRuns = [];
221
+ model = ctx.model?.id ?? "unknown";
222
+ stopReason = "stop";
223
+ stopLiveTimer();
224
+ setStatus(liveStatusText(), ctx);
225
+ liveRefresh = () => setStatus(liveStatusText(), ctx);
226
+ liveTimer = setInterval(() => {
227
+ try {
228
+ liveRefresh?.();
229
+ } catch {
230
+ stopLiveTimer();
231
+ }
232
+ }, TICK_MS);
233
+ });
234
+ pi.on("turn_start", (event, _ctx) => {
235
+ if (!running) return;
236
+ activeTurn = { index: event.turnIndex ?? turns.length + 1, startedAt: Date.now(), spans: /* @__PURE__ */ new Map() };
237
+ activeToolRuns = [];
238
+ });
239
+ pi.on("tool_execution_start", (event, _ctx) => {
240
+ if (!running || !activeTurn) return;
241
+ activeTurn.spans.set(event.toolCallId, { name: event.toolName, start: Date.now() });
242
+ });
243
+ pi.on("tool_execution_end", (event, _ctx) => {
244
+ if (!running || !activeTurn) return;
245
+ const open = activeTurn.spans.get(event.toolCallId);
246
+ if (open) {
247
+ activeTurn.spans.delete(event.toolCallId);
248
+ activeToolRuns.push({
249
+ name: event.toolName ?? open.name,
250
+ start: open.start,
251
+ end: Date.now(),
252
+ isError: Boolean(event.isError)
253
+ });
254
+ return;
255
+ }
256
+ const at = Date.now();
257
+ activeToolRuns.push({ name: event.toolName, start: at, end: at, isError: Boolean(event.isError) });
258
+ });
259
+ pi.on("turn_end", (event, _ctx) => {
260
+ if (!running || !activeTurn) return;
261
+ const endedAt = Date.now();
262
+ for (const [id, open] of activeTurn.spans) {
263
+ activeToolRuns.push({ name: open.name, start: open.start, end: endedAt, isError: false });
264
+ activeTurn.spans.delete(id);
265
+ }
266
+ const usage = event.message?.role === "assistant" ? event.message.usage : void 0;
267
+ const durationMs = endedAt - activeTurn.startedAt;
268
+ const toolMs = unionMs(activeToolRuns);
269
+ const output = usage?.output ?? 0;
270
+ turns.push({
271
+ index: activeTurn.index,
272
+ durationMs,
273
+ toolMs,
274
+ modelMs: Math.max(0, durationMs - toolMs),
275
+ outputPerSec: durationMs > 0 ? output / (durationMs / 1e3) : 0,
276
+ tools: activeToolRuns.map((run) => ({ name: run.name, ms: run.end - run.start, isError: run.isError })),
277
+ input: usage?.input ?? 0,
278
+ output,
279
+ reasoning: usage?.reasoning ?? 0,
280
+ cacheRead: usage?.cacheRead ?? 0,
281
+ cacheWrite: usage?.cacheWrite ?? 0,
282
+ totalTokens: usage?.totalTokens ?? 0,
283
+ cost: usage?.cost?.total ?? 0
284
+ });
285
+ totals.input += usage?.input ?? 0;
286
+ totals.output += output;
287
+ totals.reasoning += usage?.reasoning ?? 0;
288
+ totals.cacheRead += usage?.cacheRead ?? 0;
289
+ totals.cacheWrite += usage?.cacheWrite ?? 0;
290
+ totals.totalTokens += usage?.totalTokens ?? 0;
291
+ totals.cost += usage?.cost?.total ?? 0;
292
+ if (event.message?.stopReason) stopReason = event.message.stopReason;
293
+ activeTurn = void 0;
294
+ activeToolRuns = [];
295
+ });
296
+ pi.on("ui_prompt_start", (_event, _ctx) => {
297
+ if (!running || waitingStart !== void 0) return;
298
+ waitingStart = Date.now();
299
+ });
300
+ pi.on("ui_prompt_end", (_event, _ctx) => {
301
+ if (waitingStart === void 0) return;
302
+ waitingMs += Date.now() - waitingStart;
303
+ waitingStart = void 0;
304
+ });
305
+ pi.on("agent_settled", (_event, ctx) => {
306
+ if (!running) return;
307
+ const endedAt = Date.now();
308
+ if (waitingStart !== void 0) {
309
+ waitingMs += endedAt - waitingStart;
310
+ waitingStart = void 0;
311
+ }
312
+ const durationMs = endedAt - startedAt;
313
+ const toolMs = turns.reduce((sum, turn) => sum + turn.toolMs, 0);
314
+ const record = {
315
+ ...totals,
316
+ kind: "exchange",
317
+ index: exchangeIndex,
318
+ promptCount,
319
+ turnCount: turns.length,
320
+ turns,
321
+ startedAt,
322
+ endedAt,
323
+ durationMs,
324
+ waitingMs,
325
+ toolMs,
326
+ model,
327
+ stopReason
328
+ };
329
+ stopLiveTimer();
330
+ sessionTotals.input += totals.input;
331
+ sessionTotals.output += totals.output;
332
+ sessionTotals.reasoning += totals.reasoning;
333
+ sessionTotals.cacheRead += totals.cacheRead;
334
+ sessionTotals.cacheWrite += totals.cacheWrite;
335
+ sessionTotals.totalTokens += totals.totalTokens;
336
+ sessionTotals.cost += totals.cost;
337
+ sessionTotals.exchanges++;
338
+ sessionTotals.turnCount += turns.length;
339
+ sessionTotals.durationMs += durationMs;
340
+ sessionTotals.toolMs += toolMs;
341
+ sessionTotals.waitingMs += waitingMs;
342
+ try {
343
+ pi.appendEntry(ENTRY_TYPE, record);
344
+ } catch {
345
+ }
346
+ const parts = [`\u23F1 ${fmtDuration(durationMs)}`, plural(turns.length, "turn")];
347
+ if (toolMs > 0 && durationMs > 0 && toolMs / durationMs >= TOOL_SHARE_NOTE) {
348
+ parts.push(`tools ${fmtDuration(toolMs)}`);
349
+ }
350
+ parts.push(`out ${fmtTokens(totals.output)}`, fmtCost(totals.cost));
351
+ if (waitingMs > 0) parts.push(`waiting ${fmtDuration(waitingMs)}`);
352
+ setStatus(parts.join(" \xB7 "), ctx);
353
+ resetExchangeState();
354
+ });
355
+ pi.registerCommand("exstats", {
356
+ description: "Append a cumulative session timing card",
357
+ handler: () => {
358
+ const record = {
359
+ ...sessionTotals,
360
+ kind: "session",
361
+ index: sessionTotals.exchanges,
362
+ promptCount: sessionTotals.exchanges,
363
+ turnCount: sessionTotals.turnCount,
364
+ turns: [],
365
+ startedAt: sessionStartedAt,
366
+ endedAt: Date.now(),
367
+ durationMs: sessionTotals.durationMs,
368
+ waitingMs: sessionTotals.waitingMs,
369
+ toolMs: sessionTotals.toolMs,
370
+ model: `${plural(sessionTotals.turnCount, "turn")} tracked`,
371
+ stopReason: "session"
372
+ };
373
+ try {
374
+ pi.appendEntry(ENTRY_TYPE, record);
375
+ } catch {
376
+ }
377
+ }
378
+ });
379
+ }
380
+ export {
381
+ exchange_stats_default as default
382
+ };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "pi-exchange-stats",
3
+ "version": "0.1.0",
4
+ "description": "Show timing and cost for each Pi exchange.",
5
+ "type": "module",
6
+ "main": "./exchange-stats.js",
7
+ "exports": "./exchange-stats.js",
8
+ "files": [
9
+ "exchange-stats.js",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "keywords": [
14
+ "pi-package"
15
+ ],
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/ezoushen/pi-extensions.git",
20
+ "directory": "extensions/exchange-stats"
21
+ },
22
+ "homepage": "https://github.com/ezoushen/pi-extensions/tree/main/extensions/exchange-stats#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/ezoushen/pi-extensions/issues"
25
+ },
26
+ "peerDependencies": {
27
+ "@earendil-works/pi-coding-agent": "*",
28
+ "@earendil-works/pi-tui": "*"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "pi": {
34
+ "extensions": [
35
+ "./exchange-stats.js"
36
+ ]
37
+ }
38
+ }