@marshal/pi-turn-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/README.md +37 -0
- package/extensions/turn-stats.ts +282 -0
- package/package.json +19 -0
package/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# @marshal/pi-turn-stats
|
|
2
|
+
|
|
3
|
+
pi extension: conversation statistics (对话统计). For every exchange, records duration, token usage (input / output / cache read / cache write), and cost.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **对话流统计卡片** — after each reply, appends a stats card to the conversation stream (not sent to the LLM context).
|
|
8
|
+
- **状态栏实时显示** — bottom status bar shows the previous exchange's duration / output speed / tokens / cost.
|
|
9
|
+
- **`/turnstats` 命令** — appends a session-cumulative stats card (total exchanges, turns, tokens, cost).
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Install project-locally (recommended, supports per-project version pinning):
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# From the GitHub repo, pinned to a git tag (SSH)
|
|
17
|
+
pi install -l git:git@github.com:MarshalW/pi-turn-stats@v0.1.0
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Note
|
|
21
|
+
|
|
22
|
+
Generated stats are **local-only** — nothing is uploaded anywhere. Reads `turn_end` event usage data and wall-clock timing inside the running pi process.
|
|
23
|
+
|
|
24
|
+
## Development
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
git clone git@github.com:MarshalW/pi-turn-stats.git
|
|
28
|
+
cd pi-turn-stats
|
|
29
|
+
pi install ./ # local install for testing
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Release flow
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
git tag vX.Y.Z && git push origin main --tags
|
|
36
|
+
# then on each consumer machine: pi install -l git:...@vX.Y.Z
|
|
37
|
+
```
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turn-stats.ts — 单次对话耗时 & token 消耗统计
|
|
3
|
+
*
|
|
4
|
+
* 功能:
|
|
5
|
+
* 1. 每次用户发消息到回复完成(before_agent_start → agent_settled,含工具调用循环/重试),
|
|
6
|
+
* 在对话流中追加一张「对话统计」卡片(pi.appendEntry + registerEntryRenderer,
|
|
7
|
+
* 不参与 LLM 上下文,不会发给模型)
|
|
8
|
+
* 2. 底部状态栏实时显示上一次对话的 耗时 / 生成速度 / token / 费用(ctx.ui.setStatus)
|
|
9
|
+
* 3. /turnstats 命令追加当前会话的累计统计卡片
|
|
10
|
+
*
|
|
11
|
+
* 数据来源:
|
|
12
|
+
* - turn_end 事件携带每条 assistant 消息的 usage(input/output/cacheRead/cacheWrite/totalTokens/cost)
|
|
13
|
+
* - before_agent_start / agent_settled 界定一次对话的起止时间
|
|
14
|
+
*
|
|
15
|
+
* 生成速度(tokens/s):分子**只用输出 token**(自回归解码生成的量),
|
|
16
|
+
* 分母为整次对话的墙钟耗时。绝不能用 totalTokens —— 它包含输入/缓存读/写,
|
|
17
|
+
* 会把速度虚高几十倍(例:输入 2029 + 输出 139 → 2168/13.2s ≈ 164 t/s,
|
|
18
|
+
* 而真实的 139/13.2s ≈ 10.5 t/s)。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { Box, Text } from "@earendil-works/pi-tui";
|
|
23
|
+
|
|
24
|
+
const ENTRY_TYPE = "turn-stats";
|
|
25
|
+
|
|
26
|
+
/** 是否在对话流中追加统计卡片(设为 false 可只保留状态栏) */
|
|
27
|
+
const SHOW_CARD = true;
|
|
28
|
+
|
|
29
|
+
interface TurnStatsData {
|
|
30
|
+
kind: "exchange" | "session";
|
|
31
|
+
startTime: number;
|
|
32
|
+
endTime: number;
|
|
33
|
+
/** LLM 调用次数(一次对话可能多次调用工具形成多个 turn) */
|
|
34
|
+
turns: number;
|
|
35
|
+
/** 对话次数(exchange=1,session=累计) */
|
|
36
|
+
exchanges: number;
|
|
37
|
+
input: number;
|
|
38
|
+
output: number;
|
|
39
|
+
cacheRead: number;
|
|
40
|
+
cacheWrite: number;
|
|
41
|
+
totalTokens: number;
|
|
42
|
+
cost: number;
|
|
43
|
+
/** 生成速度:输出 tok/s(仅 autoregressive 解码输出) */
|
|
44
|
+
tokensPerSec: number;
|
|
45
|
+
model: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface TokenAccum {
|
|
49
|
+
input: number;
|
|
50
|
+
output: number;
|
|
51
|
+
cacheRead: number;
|
|
52
|
+
cacheWrite: number;
|
|
53
|
+
totalTokens: number;
|
|
54
|
+
cost: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const emptyAccum = (): TokenAccum => ({
|
|
58
|
+
input: 0,
|
|
59
|
+
output: 0,
|
|
60
|
+
cacheRead: 0,
|
|
61
|
+
cacheWrite: 0,
|
|
62
|
+
totalTokens: 0,
|
|
63
|
+
cost: 0,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
function fmtDuration(ms: number): string {
|
|
67
|
+
if (ms < 1000) return `${ms}ms`;
|
|
68
|
+
const s = ms / 1000;
|
|
69
|
+
if (s < 60) return `${s.toFixed(1)}s`;
|
|
70
|
+
const m = Math.floor(s / 60);
|
|
71
|
+
const rem = Math.round(s % 60);
|
|
72
|
+
return rem > 0 ? `${m}m${rem}s` : `${m}m`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function fmtTokens(n: number): string {
|
|
76
|
+
if (n < 1000) return `${Math.round(n)}`;
|
|
77
|
+
return `${(n / 1000).toFixed(1)}k`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function fmtThroughput(tps: number): string {
|
|
81
|
+
if (tps <= 0) return "—";
|
|
82
|
+
if (tps < 1000) return `${Math.round(tps)} tok/s`;
|
|
83
|
+
return `${(tps / 1000).toFixed(1)}k tok/s`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 生成速度 = 输出 token 数(自回归解码量)÷ 墙钟耗时。
|
|
88
|
+
* 分子只用 output,排除 input / cacheRead / cacheWrite / totalTokens。
|
|
89
|
+
*/
|
|
90
|
+
function calcOutputPerSec(outputTokens: number, elapsedMs: number): number {
|
|
91
|
+
return elapsedMs > 0 ? outputTokens / (elapsedMs / 1000) : 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function fmtCost(c: number): string {
|
|
95
|
+
if (c <= 0) return "$0";
|
|
96
|
+
if (c < 0.01) return `$${c.toFixed(5)}`;
|
|
97
|
+
return `$${c.toFixed(4)}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default function (pi: ExtensionAPI) {
|
|
101
|
+
// ---- 会话累计统计 ----
|
|
102
|
+
const sessionTotals = {
|
|
103
|
+
...emptyAccum(),
|
|
104
|
+
exchanges: 0,
|
|
105
|
+
durationMs: 0,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// ---- 单次对话统计 ----
|
|
109
|
+
let running = false;
|
|
110
|
+
let startTime = 0;
|
|
111
|
+
let turnCount = 0;
|
|
112
|
+
let accum = emptyAccum();
|
|
113
|
+
let lastModel = "";
|
|
114
|
+
|
|
115
|
+
// ===== 统计卡片渲染(对话流内) =====
|
|
116
|
+
pi.registerEntryRenderer<TurnStatsData>(ENTRY_TYPE, (entry, { expanded }, theme) => {
|
|
117
|
+
const d = entry.data;
|
|
118
|
+
if (!d) return new Text(theme.fg("dim", "(no stats)"), 0, 0);
|
|
119
|
+
|
|
120
|
+
const isSession = d.kind === "session";
|
|
121
|
+
const dur = fmtDuration(d.endTime - d.startTime);
|
|
122
|
+
const cost = fmtCost(d.cost);
|
|
123
|
+
|
|
124
|
+
const box = new Box(1, 1, (s) => theme.bg("customMessageBg", s));
|
|
125
|
+
|
|
126
|
+
// 标题行
|
|
127
|
+
const title = isSession ? "📊 会话统计" : "⏱ 对话统计";
|
|
128
|
+
box.addChild(
|
|
129
|
+
new Text(
|
|
130
|
+
theme.fg("accent", theme.bold(title)) +
|
|
131
|
+
theme.fg("dim", ` ${d.model}`),
|
|
132
|
+
0,
|
|
133
|
+
0,
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// 耗时 / 次数 / 生成速度 / 费用
|
|
138
|
+
const genTps = fmtThroughput(d.tokensPerSec);
|
|
139
|
+
const meta = isSession
|
|
140
|
+
? `累计 ${d.exchanges} 次对话 · ${d.turns} 次 LLM 调用 · 输出 ${genTps} · 费用 `
|
|
141
|
+
: `耗时 ${dur} · ${d.turns} 次 LLM 调用 · 输出 ${genTps} · 费用 `;
|
|
142
|
+
box.addChild(
|
|
143
|
+
new Text(
|
|
144
|
+
theme.fg("dim", meta) + theme.fg("text", cost),
|
|
145
|
+
0,
|
|
146
|
+
0,
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// token 明细
|
|
151
|
+
box.addChild(
|
|
152
|
+
new Text(
|
|
153
|
+
theme.fg("dim", "token 输入 ") +
|
|
154
|
+
theme.fg("text", fmtTokens(d.input)) +
|
|
155
|
+
theme.fg("dim", " · 输出 ") +
|
|
156
|
+
theme.fg("text", fmtTokens(d.output)) +
|
|
157
|
+
theme.fg("dim", " · 缓存读 ") +
|
|
158
|
+
theme.fg("text", fmtTokens(d.cacheRead)) +
|
|
159
|
+
theme.fg("dim", " / 写 ") +
|
|
160
|
+
theme.fg("text", fmtTokens(d.cacheWrite)) +
|
|
161
|
+
theme.fg("dim", " · 合计 ") +
|
|
162
|
+
theme.fg("text", fmtTokens(d.totalTokens)),
|
|
163
|
+
0,
|
|
164
|
+
0,
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// 展开时显示起止时间
|
|
169
|
+
if (expanded && d.startTime > 0) {
|
|
170
|
+
box.addChild(
|
|
171
|
+
new Text(
|
|
172
|
+
theme.fg("dim", `${new Date(d.startTime).toLocaleString()} → ${new Date(d.endTime).toLocaleString()}`),
|
|
173
|
+
0,
|
|
174
|
+
0,
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return box;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ===== 会话开始:初始化状态栏 =====
|
|
183
|
+
pi.on("session_start", (_event, ctx) => {
|
|
184
|
+
if (!ctx.hasUI) return;
|
|
185
|
+
ctx.ui.setStatus("turn-stats", ctx.ui.theme.fg("dim", "⏱ 等待对话…"));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ===== 用户提交消息:开始计时 =====
|
|
189
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
190
|
+
running = true;
|
|
191
|
+
startTime = Date.now();
|
|
192
|
+
turnCount = 0;
|
|
193
|
+
accum = emptyAccum();
|
|
194
|
+
lastModel = ctx.model?.id ?? "unknown";
|
|
195
|
+
if (ctx.hasUI) {
|
|
196
|
+
ctx.ui.setStatus("turn-stats", ctx.ui.theme.fg("dim", "⏱ 统计中…"));
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ===== 每个 LLM turn 结束:累计 usage =====
|
|
201
|
+
pi.on("turn_end", (event, _ctx) => {
|
|
202
|
+
if (!running) return;
|
|
203
|
+
turnCount++;
|
|
204
|
+
const msg = event.message;
|
|
205
|
+
if (msg?.role !== "assistant") return;
|
|
206
|
+
const u = msg.usage;
|
|
207
|
+
if (u) {
|
|
208
|
+
accum.input += u.input ?? 0;
|
|
209
|
+
accum.output += u.output ?? 0;
|
|
210
|
+
accum.cacheRead += u.cacheRead ?? 0;
|
|
211
|
+
accum.cacheWrite += u.cacheWrite ?? 0;
|
|
212
|
+
accum.totalTokens += u.totalTokens ?? 0;
|
|
213
|
+
accum.cost += u.cost?.total ?? 0;
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ===== 回复完成:出卡片 + 更新状态栏 =====
|
|
218
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
219
|
+
if (!running) return;
|
|
220
|
+
running = false;
|
|
221
|
+
const endTime = Date.now();
|
|
222
|
+
const durMs = endTime - startTime;
|
|
223
|
+
// 生成速度只统计输出 token(自回归解码),排除输入/缓存读/写
|
|
224
|
+
const genTps = calcOutputPerSec(accum.output, durMs);
|
|
225
|
+
|
|
226
|
+
// 累加会话统计
|
|
227
|
+
for (const key of ["input", "output", "cacheRead", "cacheWrite", "totalTokens", "cost"] as const) {
|
|
228
|
+
sessionTotals[key] += accum[key];
|
|
229
|
+
}
|
|
230
|
+
sessionTotals.exchanges++;
|
|
231
|
+
sessionTotals.durationMs += durMs;
|
|
232
|
+
|
|
233
|
+
// 至少有一次 LLM 调用才出卡片(中途 Esc 取消且未发起调用则跳过)
|
|
234
|
+
if (SHOW_CARD && turnCount > 0) {
|
|
235
|
+
pi.appendEntry<TurnStatsData>(ENTRY_TYPE, {
|
|
236
|
+
kind: "exchange",
|
|
237
|
+
startTime,
|
|
238
|
+
endTime,
|
|
239
|
+
turns: turnCount,
|
|
240
|
+
exchanges: 1,
|
|
241
|
+
...accum,
|
|
242
|
+
tokensPerSec: genTps,
|
|
243
|
+
model: lastModel,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (ctx.hasUI) {
|
|
248
|
+
ctx.ui.setStatus(
|
|
249
|
+
"turn-stats",
|
|
250
|
+
ctx.ui.theme.fg(
|
|
251
|
+
"dim",
|
|
252
|
+
`⏱ ${fmtDuration(durMs)} · 输出 ${fmtThroughput(genTps)} · ${fmtTokens(accum.totalTokens)} tok · ${fmtCost(accum.cost)}`,
|
|
253
|
+
),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ===== /turnstats:追加会话累计统计卡片 =====
|
|
259
|
+
pi.registerCommand("turnstats", {
|
|
260
|
+
description: "追加当前会话的累计耗时与 token 统计卡片",
|
|
261
|
+
handler: async () => {
|
|
262
|
+
const sessDurMs = sessionTotals.durationMs;
|
|
263
|
+
// 生成速度只统计输出 token(自回归解码),排除输入/缓存读/写
|
|
264
|
+
const sessGenTps = calcOutputPerSec(sessionTotals.output, sessDurMs);
|
|
265
|
+
pi.appendEntry<TurnStatsData>(ENTRY_TYPE, {
|
|
266
|
+
kind: "session",
|
|
267
|
+
startTime: sessDurMs > 0 ? Date.now() - sessDurMs : Date.now(),
|
|
268
|
+
endTime: Date.now(),
|
|
269
|
+
turns: sessionTotals.exchanges,
|
|
270
|
+
exchanges: sessionTotals.exchanges,
|
|
271
|
+
input: sessionTotals.input,
|
|
272
|
+
output: sessionTotals.output,
|
|
273
|
+
cacheRead: sessionTotals.cacheRead,
|
|
274
|
+
cacheWrite: sessionTotals.cacheWrite,
|
|
275
|
+
totalTokens: sessionTotals.totalTokens,
|
|
276
|
+
cost: sessionTotals.cost,
|
|
277
|
+
tokensPerSec: sessGenTps,
|
|
278
|
+
model: "累计",
|
|
279
|
+
});
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@marshal/pi-turn-stats",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "pi extension: per-exchange duration, token & cost stats for conversations (stream card + status bar + /turnstats command)",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package"
|
|
7
|
+
],
|
|
8
|
+
"type": "module",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"files": [
|
|
11
|
+
"extensions",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"pi": {
|
|
15
|
+
"extensions": [
|
|
16
|
+
"./extensions/turn-stats.ts"
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
}
|