@marshal/pi-turn-stats 0.1.3 → 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 CHANGED
@@ -1,61 +1,65 @@
1
1
  # @marshal/pi-turn-stats
2
2
 
3
- pi 扩展:对话统计(Turn Stats)。每次对话交换后,自动记录耗时、token 用量(input / output / cache read / cache write)和费用。
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
- - **对话流统计卡片**每次回复后,在对话流中追加统计卡片(不会进入 LLM 上下文)。
8
- - **状态栏实时显示**底部状态栏显示上一轮对话的耗时 / 输出速度 / token / 费用。
9
- - **`/turnstats` 命令**追加会话累计统计卡片(总交换次数、总轮次、总 token、总费用)。
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
+
13
+ ![Turn Stats Demo](turn-stats.gif)
14
+
15
+ ## Screenshot
12
16
 
13
17
  ![Turn Stats](https://github.com/MarshalW/pi-turn-stats/raw/v0.1.2/turn-stats.jpg)
14
18
 
15
- ## 安装
19
+ ## Installation
16
20
 
17
- **推荐方式**:从 npm 安装,国内访问稳定,且可查看安装统计。
21
+ **Recommended**: Install via npm for stable access and install statistics.
18
22
 
19
23
  ```bash
20
- # pi 直接安装 npm
24
+ # Install directly with pi
21
25
  pi install -l npm:@marshal/pi-turn-stats
22
26
  ```
23
27
 
24
- 或使用标准 npm 安装:
28
+ Or standard npm install:
25
29
 
26
30
  ```bash
27
31
  npm install @marshal/pi-turn-stats
28
32
  ```
29
33
 
30
- > 注意:`pi install -l npm:...` pi 推荐的本地安装方式,会将扩展安装到当前项目;普通 `npm install` 仅下载包,不自动注册为 pi 扩展。
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
- **备选方式**:从 GitHub 安装(适合需要源码或自定义修改的场景)。
36
+ **Alternative**: Install from GitHub (for source access or custom modifications).
33
37
 
34
38
  ```bash
35
- # GitHub 仓库安装,指定 tagSSH
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
- > 国内用户建议优先使用 npm 方式,GitHub 访问可能不稳定。
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
- 生成的统计数据**仅本地存储**,不会上传到任何服务器。读取运行中 pi 进程内的 `turn_end` 事件数据和实际耗时。
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
- # 发布到 npmnpm version patch && npm publish --access public
58
- # 消费端安装:
59
- # npm 方式(推荐):pi install -l npm:@marshal/pi-turn-stats
60
- # git 方式(备选):pi install -l git:git@github.com:MarshalW/pi-turn-stats@vX.Y.Z
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
  ```
@@ -1,21 +1,22 @@
1
1
  /**
2
- * turn-stats.ts — 单次对话耗时 & token 消耗统计
2
+ * turn-stats.ts — per-exchange duration, token & cost stats
3
3
  *
4
- * 功能:
5
- * 1. 每次用户发消息到回复完成(before_agent_start → agent_settled,含工具调用循环/重试),
6
- * 在对话流中追加一张「对话统计」卡片(pi.appendEntry + registerEntryRenderer,
7
- * 不参与 LLM 上下文,不会发给模型)
8
- * 2. 底部状态栏实时显示上一次对话的 耗时 / 生成速度 / token / 费用(ctx.ui.setStatus)
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 事件携带每条 assistant 消息的 usage(input/output/cacheRead/cacheWrite/totalTokens/cost)
13
- * - before_agent_start / agent_settled 界定一次对话的起止时间
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
- * 生成速度(tokens/s):分子**只用输出 token**(自回归解码生成的量),
16
- * 分母为整次对话的墙钟耗时。绝不能用 totalTokens —— 它包含输入/缓存读/写,
17
- * 会把速度虚高几十倍(例:输入 2029 + 输出 139 2168/13.2s 164 t/s,
18
- * 而真实的 139/13.2s 10.5 t/s)。
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
- /** 是否在对话流中追加统计卡片(设为 false 可只保留状态栏) */
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 调用次数(一次对话可能多次调用工具形成多个 turn) */
99
+ /** LLM call count (one exchange may trigger multiple tool-call turns) */
34
100
  turns: number;
35
- /** 对话次数(exchange=1,session=累计) */
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
- /** 生成速度:输出 tok/s(仅 autoregressive 解码输出) */
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
- * 生成速度 = 输出 token 数(自回归解码量)÷ 墙钟耗时。
88
- * 分子只用 output,排除 input / cacheRead / cacheWrite / totalTokens
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
- ? `累计 ${d.exchanges} 次对话 · ${d.turns} 次 LLM 调用 · 输出 ${genTps} · 费用 `
141
- : `耗时 ${dur} · ${d.turns} 次 LLM 调用 · 输出 ${genTps} · 费用 `;
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", "token 输入 ") +
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
- // ===== 每个 LLM turn 结束:累计 usage =====
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
- `⏱ ${fmtDuration(durMs)} · 输出 ${fmtThroughput(genTps)} · ${fmtTokens(accum.totalTokens)} tok · ${fmtCost(accum.cost)}`,
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: "追加当前会话的累计耗时与 token 统计卡片",
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",
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