@marshal/pi-turn-stats 0.1.2 → 0.1.4
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 +28 -24
- package/extensions/turn-stats.ts +114 -50
- package/package.json +3 -2
- package/turn-stats.gif +0 -0
package/README.md
CHANGED
|
@@ -1,61 +1,65 @@
|
|
|
1
1
|
# @marshal/pi-turn-stats
|
|
2
2
|
|
|
3
|
-
pi
|
|
3
|
+
A pi extension that automatically tracks per-exchange duration, token usage (input / output / cache read / cache write), and cost after every conversation turn.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Features
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
- **`/turnstats`
|
|
7
|
+
- **Stats card in conversation stream** — After each reply, a stats card is appended to the stream (does not enter LLM context).
|
|
8
|
+
- **Real-time status bar** — The bottom status bar shows the last exchange's duration, throughput, token count, and cost.
|
|
9
|
+
- **`/turnstats` command** — Appends a session cumulative stats card (total exchanges, total LLM calls, total tokens, total cost).
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## Demo
|
|
12
12
|
|
|
13
|
-

|
|
14
14
|
|
|
15
|
-
##
|
|
15
|
+
## Screenshot
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
**Recommended**: Install via npm for stable access and install statistics.
|
|
18
22
|
|
|
19
23
|
```bash
|
|
20
|
-
#
|
|
24
|
+
# Install directly with pi
|
|
21
25
|
pi install -l npm:@marshal/pi-turn-stats
|
|
22
26
|
```
|
|
23
27
|
|
|
24
|
-
|
|
28
|
+
Or standard npm install:
|
|
25
29
|
|
|
26
30
|
```bash
|
|
27
31
|
npm install @marshal/pi-turn-stats
|
|
28
32
|
```
|
|
29
33
|
|
|
30
|
-
>
|
|
34
|
+
> Note: `pi install -l npm:...` is the pi-recommended local install method that registers the extension for the current project. Plain `npm install` only downloads the package without registering it as a pi extension.
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
**Alternative**: Install from GitHub (for source access or custom modifications).
|
|
33
37
|
|
|
34
38
|
```bash
|
|
35
|
-
#
|
|
39
|
+
# Install from GitHub repo, specifying tag (SSH)
|
|
36
40
|
pi install -l git:git@github.com:MarshalW/pi-turn-stats@v0.1.1
|
|
37
41
|
```
|
|
38
42
|
|
|
39
|
-
>
|
|
43
|
+
> Users in China are encouraged to use the npm method, as GitHub access may be unreliable.
|
|
40
44
|
|
|
41
|
-
##
|
|
45
|
+
## Notes
|
|
42
46
|
|
|
43
|
-
|
|
47
|
+
Statistics are **stored locally only** and are not uploaded to any server. Data is read from the running pi process's `turn_end` events and wall-clock timing.
|
|
44
48
|
|
|
45
|
-
##
|
|
49
|
+
## Development
|
|
46
50
|
|
|
47
51
|
```bash
|
|
48
52
|
git clone git@github.com:MarshalW/pi-turn-stats.git
|
|
49
53
|
cd pi-turn-stats
|
|
50
|
-
pi install ./ #
|
|
54
|
+
pi install ./ # local install for testing
|
|
51
55
|
```
|
|
52
56
|
|
|
53
|
-
##
|
|
57
|
+
## Release Process
|
|
54
58
|
|
|
55
59
|
```bash
|
|
56
60
|
git tag vX.Y.Z && git push origin main --tags
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
# npm
|
|
60
|
-
# git
|
|
61
|
+
# Publish to npm: npm version patch && npm publish --access public
|
|
62
|
+
# Consumer install:
|
|
63
|
+
# npm (recommended): pi install -l npm:@marshal/pi-turn-stats
|
|
64
|
+
# git (fallback): pi install -l git:git@github.com:MarshalW/pi-turn-stats@vX.Y.Z
|
|
61
65
|
```
|
package/extensions/turn-stats.ts
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* turn-stats.ts —
|
|
2
|
+
* turn-stats.ts — per-exchange duration, token & cost stats
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* 1.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 2.
|
|
9
|
-
* 3. /turnstats
|
|
4
|
+
* Features:
|
|
5
|
+
* 1. After each user→reply exchange (before_agent_start → agent_settled),
|
|
6
|
+
* append a stats card to the conversation stream (via pi.appendEntry +
|
|
7
|
+
* registerEntryRenderer, not part of LLM context).
|
|
8
|
+
* 2. Status bar shows last exchange duration / throughput / tokens / cost.
|
|
9
|
+
* 3. /turnstats command appends a session cumulative stats card.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
* - turn_end
|
|
13
|
-
*
|
|
11
|
+
* Data sources:
|
|
12
|
+
* - turn_end event carries per-assistant-message usage
|
|
13
|
+
* (input/output/cacheRead/cacheWrite/totalTokens/cost)
|
|
14
|
+
* - before_agent_start / agent_settled delimit the wall-clock duration
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Throughput (tok/s): numerator = output tokens only (autoregressive decode),
|
|
17
|
+
* denominator = wall-clock time of the whole exchange.
|
|
18
|
+
* Never use totalTokens — it inflates throughput dozens of times because it
|
|
19
|
+
* includes input + cache read/write.
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -23,16 +24,81 @@ import { Box, Text } from "@earendil-works/pi-tui";
|
|
|
23
24
|
|
|
24
25
|
const ENTRY_TYPE = "turn-stats";
|
|
25
26
|
|
|
26
|
-
/**
|
|
27
|
+
/** Whether to append a stats card after each exchange (false = status bar only) */
|
|
27
28
|
const SHOW_CARD = true;
|
|
28
29
|
|
|
30
|
+
// ===== i18n: auto-detect locale from system environment =====
|
|
31
|
+
|
|
32
|
+
type Locale = "zh" | "en";
|
|
33
|
+
|
|
34
|
+
function detectLocale(): Locale {
|
|
35
|
+
const env = process.env.LANG ?? process.env.LC_ALL ?? "";
|
|
36
|
+
if (env.startsWith("zh")) return "zh";
|
|
37
|
+
try {
|
|
38
|
+
const resolved = Intl.DateTimeFormat().resolvedLocales();
|
|
39
|
+
if (resolved.length > 0 && resolved[0].startsWith("zh")) return "zh";
|
|
40
|
+
} catch { /* Intl not available — fall through */ }
|
|
41
|
+
return "en";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const locale: Locale = detectLocale();
|
|
45
|
+
|
|
46
|
+
type Msg = string | ((...args: any[]) => string);
|
|
47
|
+
|
|
48
|
+
const messages: Record<Locale, Record<string, Msg>> = {
|
|
49
|
+
zh: {
|
|
50
|
+
cardTitleExchange: "⏱ 对话统计",
|
|
51
|
+
cardTitleSession: "📊 会话统计",
|
|
52
|
+
metaExchange: (dur: string, turns: number, tps: string) =>
|
|
53
|
+
`耗时 ${dur} · ${turns} 次 LLM 调用 · 输出 ${tps} · 费用 `,
|
|
54
|
+
metaSession: (ex: number, turns: number, tps: string) =>
|
|
55
|
+
`累计 ${ex} 次对话 · ${turns} 次 LLM 调用 · 输出 ${tps} · 费用 `,
|
|
56
|
+
tokenInput: "token 输入 ",
|
|
57
|
+
tokenOutput: " · 输出 ",
|
|
58
|
+
cacheRead: " · 缓存读 ",
|
|
59
|
+
cacheWrite: " / 写 ",
|
|
60
|
+
tokenTotal: " · 合计 ",
|
|
61
|
+
statusWaiting: "⏱ 等待对话…",
|
|
62
|
+
statusRunning: "⏱ 统计中…",
|
|
63
|
+
cmdDescription: "追加当前会话的累计耗时与 token 统计卡片",
|
|
64
|
+
sessionModel: "累计",
|
|
65
|
+
},
|
|
66
|
+
en: {
|
|
67
|
+
cardTitleExchange: "⏱ Turn Stats",
|
|
68
|
+
cardTitleSession: "📊 Session Stats",
|
|
69
|
+
metaExchange: (dur: string, turns: number, tps: string) =>
|
|
70
|
+
`${dur} · ${turns} LLM calls · output ${tps} · cost `,
|
|
71
|
+
metaSession: (ex: number, turns: number, tps: string) =>
|
|
72
|
+
`${ex} exchanges · ${turns} LLM calls · output ${tps} · cost `,
|
|
73
|
+
tokenInput: "token input ",
|
|
74
|
+
tokenOutput: " · output ",
|
|
75
|
+
cacheRead: " · cache read ",
|
|
76
|
+
cacheWrite: " / write ",
|
|
77
|
+
tokenTotal: " · total ",
|
|
78
|
+
statusWaiting: "⏱ Waiting…",
|
|
79
|
+
statusRunning: "⏱ Processing…",
|
|
80
|
+
statusDone: (dur: string, tps: string, tokens: string, cost: string) =>
|
|
81
|
+
`⏱ ${dur} · output ${tps} · ${tokens} tok · ${cost}`,
|
|
82
|
+
cmdDescription: "Append session cumulative turn stats card",
|
|
83
|
+
sessionModel: "Cumulative",
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function t(key: string, ...args: any[]): string {
|
|
88
|
+
const msg = messages[locale][key] as Msg | undefined;
|
|
89
|
+
if (typeof msg === "function") return msg(...args);
|
|
90
|
+
return msg ?? key;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ===== end i18n =====
|
|
94
|
+
|
|
29
95
|
interface TurnStatsData {
|
|
30
96
|
kind: "exchange" | "session";
|
|
31
97
|
startTime: number;
|
|
32
98
|
endTime: number;
|
|
33
|
-
/** LLM
|
|
99
|
+
/** LLM call count (one exchange may trigger multiple tool-call turns) */
|
|
34
100
|
turns: number;
|
|
35
|
-
/**
|
|
101
|
+
/** Exchange count (1 for a single exchange, cumulative for session) */
|
|
36
102
|
exchanges: number;
|
|
37
103
|
input: number;
|
|
38
104
|
output: number;
|
|
@@ -40,7 +106,7 @@ interface TurnStatsData {
|
|
|
40
106
|
cacheWrite: number;
|
|
41
107
|
totalTokens: number;
|
|
42
108
|
cost: number;
|
|
43
|
-
/**
|
|
109
|
+
/** Throughput: output tok/s (autoregressive decode only) */
|
|
44
110
|
tokensPerSec: number;
|
|
45
111
|
model: string;
|
|
46
112
|
}
|
|
@@ -84,8 +150,9 @@ function fmtThroughput(tps: number): string {
|
|
|
84
150
|
}
|
|
85
151
|
|
|
86
152
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
153
|
+
* Throughput = output tokens (autoregressive decode) ÷ wall-clock elapsed.
|
|
154
|
+
* Only output tokens — input / cacheRead / cacheWrite / totalTokens are
|
|
155
|
+
* excluded to avoid inflating throughput.
|
|
89
156
|
*/
|
|
90
157
|
function calcOutputPerSec(outputTokens: number, elapsedMs: number): number {
|
|
91
158
|
return elapsedMs > 0 ? outputTokens / (elapsedMs / 1000) : 0;
|
|
@@ -98,21 +165,21 @@ function fmtCost(c: number): string {
|
|
|
98
165
|
}
|
|
99
166
|
|
|
100
167
|
export default function (pi: ExtensionAPI) {
|
|
101
|
-
// ----
|
|
168
|
+
// ---- session cumulative stats ----
|
|
102
169
|
const sessionTotals = {
|
|
103
170
|
...emptyAccum(),
|
|
104
171
|
exchanges: 0,
|
|
105
172
|
durationMs: 0,
|
|
106
173
|
};
|
|
107
174
|
|
|
108
|
-
// ----
|
|
175
|
+
// ---- per-exchange stats ----
|
|
109
176
|
let running = false;
|
|
110
177
|
let startTime = 0;
|
|
111
178
|
let turnCount = 0;
|
|
112
179
|
let accum = emptyAccum();
|
|
113
180
|
let lastModel = "";
|
|
114
181
|
|
|
115
|
-
// =====
|
|
182
|
+
// ===== Stats card renderer (in conversation stream) =====
|
|
116
183
|
pi.registerEntryRenderer<TurnStatsData>(ENTRY_TYPE, (entry, { expanded }, theme) => {
|
|
117
184
|
const d = entry.data;
|
|
118
185
|
if (!d) return new Text(theme.fg("dim", "(no stats)"), 0, 0);
|
|
@@ -120,11 +187,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
120
187
|
const isSession = d.kind === "session";
|
|
121
188
|
const dur = fmtDuration(d.endTime - d.startTime);
|
|
122
189
|
const cost = fmtCost(d.cost);
|
|
190
|
+
const genTps = fmtThroughput(d.tokensPerSec);
|
|
123
191
|
|
|
124
192
|
const box = new Box(1, 1, (s) => theme.bg("customMessageBg", s));
|
|
125
193
|
|
|
126
|
-
//
|
|
127
|
-
const title = isSession ? "
|
|
194
|
+
// title line
|
|
195
|
+
const title = isSession ? t("cardTitleSession") : t("cardTitleExchange");
|
|
128
196
|
box.addChild(
|
|
129
197
|
new Text(
|
|
130
198
|
theme.fg("accent", theme.bold(title)) +
|
|
@@ -134,11 +202,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
134
202
|
),
|
|
135
203
|
);
|
|
136
204
|
|
|
137
|
-
//
|
|
138
|
-
const genTps = fmtThroughput(d.tokensPerSec);
|
|
205
|
+
// duration / count / throughput / cost
|
|
139
206
|
const meta = isSession
|
|
140
|
-
?
|
|
141
|
-
:
|
|
207
|
+
? t("metaSession", d.exchanges, d.turns, genTps)
|
|
208
|
+
: t("metaExchange", dur, d.turns, genTps);
|
|
142
209
|
box.addChild(
|
|
143
210
|
new Text(
|
|
144
211
|
theme.fg("dim", meta) + theme.fg("text", cost),
|
|
@@ -147,25 +214,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
147
214
|
),
|
|
148
215
|
);
|
|
149
216
|
|
|
150
|
-
// token
|
|
217
|
+
// token breakdown
|
|
151
218
|
box.addChild(
|
|
152
219
|
new Text(
|
|
153
|
-
theme.fg("dim", "
|
|
220
|
+
theme.fg("dim", t("tokenInput")) +
|
|
154
221
|
theme.fg("text", fmtTokens(d.input)) +
|
|
155
|
-
theme.fg("dim", "
|
|
222
|
+
theme.fg("dim", t("tokenOutput")) +
|
|
156
223
|
theme.fg("text", fmtTokens(d.output)) +
|
|
157
|
-
theme.fg("dim", "
|
|
224
|
+
theme.fg("dim", t("cacheRead")) +
|
|
158
225
|
theme.fg("text", fmtTokens(d.cacheRead)) +
|
|
159
|
-
theme.fg("dim", "
|
|
226
|
+
theme.fg("dim", t("cacheWrite")) +
|
|
160
227
|
theme.fg("text", fmtTokens(d.cacheWrite)) +
|
|
161
|
-
theme.fg("dim", "
|
|
228
|
+
theme.fg("dim", t("tokenTotal")) +
|
|
162
229
|
theme.fg("text", fmtTokens(d.totalTokens)),
|
|
163
230
|
0,
|
|
164
231
|
0,
|
|
165
232
|
),
|
|
166
233
|
);
|
|
167
234
|
|
|
168
|
-
//
|
|
235
|
+
// expanded: show time range
|
|
169
236
|
if (expanded && d.startTime > 0) {
|
|
170
237
|
box.addChild(
|
|
171
238
|
new Text(
|
|
@@ -179,13 +246,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
179
246
|
return box;
|
|
180
247
|
});
|
|
181
248
|
|
|
182
|
-
// =====
|
|
249
|
+
// ===== Session start: init status bar =====
|
|
183
250
|
pi.on("session_start", (_event, ctx) => {
|
|
184
251
|
if (!ctx.hasUI) return;
|
|
185
|
-
ctx.ui.setStatus("turn-stats", ctx.ui.theme.fg("dim", "
|
|
252
|
+
ctx.ui.setStatus("turn-stats", ctx.ui.theme.fg("dim", t("statusWaiting")));
|
|
186
253
|
});
|
|
187
254
|
|
|
188
|
-
// =====
|
|
255
|
+
// ===== User submits: start timer =====
|
|
189
256
|
pi.on("before_agent_start", (_event, ctx) => {
|
|
190
257
|
running = true;
|
|
191
258
|
startTime = Date.now();
|
|
@@ -193,11 +260,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
193
260
|
accum = emptyAccum();
|
|
194
261
|
lastModel = ctx.model?.id ?? "unknown";
|
|
195
262
|
if (ctx.hasUI) {
|
|
196
|
-
ctx.ui.setStatus("turn-stats", ctx.ui.theme.fg("dim", "
|
|
263
|
+
ctx.ui.setStatus("turn-stats", ctx.ui.theme.fg("dim", t("statusRunning")));
|
|
197
264
|
}
|
|
198
265
|
});
|
|
199
266
|
|
|
200
|
-
// =====
|
|
267
|
+
// ===== Each LLM turn ends: accumulate usage =====
|
|
201
268
|
pi.on("turn_end", (event, _ctx) => {
|
|
202
269
|
if (!running) return;
|
|
203
270
|
turnCount++;
|
|
@@ -214,23 +281,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
214
281
|
}
|
|
215
282
|
});
|
|
216
283
|
|
|
217
|
-
// =====
|
|
284
|
+
// ===== Reply settled: emit card + update status bar =====
|
|
218
285
|
pi.on("agent_settled", (_event, ctx) => {
|
|
219
286
|
if (!running) return;
|
|
220
287
|
running = false;
|
|
221
288
|
const endTime = Date.now();
|
|
222
289
|
const durMs = endTime - startTime;
|
|
223
|
-
// 生成速度只统计输出 token(自回归解码),排除输入/缓存读/写
|
|
224
290
|
const genTps = calcOutputPerSec(accum.output, durMs);
|
|
225
291
|
|
|
226
|
-
//
|
|
292
|
+
// accumulate session totals
|
|
227
293
|
for (const key of ["input", "output", "cacheRead", "cacheWrite", "totalTokens", "cost"] as const) {
|
|
228
294
|
sessionTotals[key] += accum[key];
|
|
229
295
|
}
|
|
230
296
|
sessionTotals.exchanges++;
|
|
231
297
|
sessionTotals.durationMs += durMs;
|
|
232
298
|
|
|
233
|
-
// 至少有一次 LLM 调用才出卡片(中途 Esc 取消且未发起调用则跳过)
|
|
234
299
|
if (SHOW_CARD && turnCount > 0) {
|
|
235
300
|
pi.appendEntry<TurnStatsData>(ENTRY_TYPE, {
|
|
236
301
|
kind: "exchange",
|
|
@@ -249,18 +314,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
249
314
|
"turn-stats",
|
|
250
315
|
ctx.ui.theme.fg(
|
|
251
316
|
"dim",
|
|
252
|
-
|
|
317
|
+
t("statusDone", fmtDuration(durMs), fmtThroughput(genTps), fmtTokens(accum.totalTokens), fmtCost(accum.cost)),
|
|
253
318
|
),
|
|
254
319
|
);
|
|
255
320
|
}
|
|
256
321
|
});
|
|
257
322
|
|
|
258
|
-
// ===== /turnstats
|
|
323
|
+
// ===== /turnstats: append session cumulative stats card =====
|
|
259
324
|
pi.registerCommand("turnstats", {
|
|
260
|
-
description: "
|
|
325
|
+
description: t("cmdDescription"),
|
|
261
326
|
handler: async () => {
|
|
262
327
|
const sessDurMs = sessionTotals.durationMs;
|
|
263
|
-
// 生成速度只统计输出 token(自回归解码),排除输入/缓存读/写
|
|
264
328
|
const sessGenTps = calcOutputPerSec(sessionTotals.output, sessDurMs);
|
|
265
329
|
pi.appendEntry<TurnStatsData>(ENTRY_TYPE, {
|
|
266
330
|
kind: "session",
|
|
@@ -275,7 +339,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
275
339
|
totalTokens: sessionTotals.totalTokens,
|
|
276
340
|
cost: sessionTotals.cost,
|
|
277
341
|
tokensPerSec: sessGenTps,
|
|
278
|
-
model: "
|
|
342
|
+
model: t("sessionModel"),
|
|
279
343
|
});
|
|
280
344
|
},
|
|
281
345
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marshal/pi-turn-stats",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "pi extension: per-exchange duration, token & cost stats for conversations (stream card + status bar + /turnstats command)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"extensions",
|
|
12
12
|
"README.md",
|
|
13
|
-
"turn-stats.jpg"
|
|
13
|
+
"turn-stats.jpg",
|
|
14
|
+
"turn-stats.gif"
|
|
14
15
|
],
|
|
15
16
|
"pi": {
|
|
16
17
|
"extensions": [
|
package/turn-stats.gif
ADDED
|
Binary file
|