pi-metrics 0.2.0 → 0.2.2

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,6 +1,6 @@
1
- # pi-hud
1
+ # pi-metrics
2
2
 
3
- Heads-up session timing for the [Pi coding agent](https://github.com/earendil-works/pi): a live elapsed timer in the working spinner plus per-turn and total run summaries in the chat flow.
3
+ Session metrics for the [Pi coding agent](https://github.com/earendil-works/pi): a live elapsed timer, per-turn summaries, and token-generation telemetry.
4
4
 
5
5
  [中文文档](./README.zh-CN.md)
6
6
 
@@ -9,17 +9,25 @@ Heads-up session timing for the [Pi coding agent](https://github.com/earendil-wo
9
9
  - While the agent is working, the spinner shows the **total elapsed time since you sent the message** (for example `⏱ 47s`). It keeps counting across turns instead of resetting per turn.
10
10
  - When each turn ends, a dim line shows that turn's precise duration (`⏱ Turn elapsed 8.2s`).
11
11
  - When the agent fully settles (`agent_settled` — including auto-retries, compaction continuations, or Esc interruption), a final line shows the **total elapsed time from message send to stop** (`⏱ Total elapsed 18.9s`).
12
+ - After each LLM turn, a notification reports TPS, TTFT, token counts, generation time, stalls, and blended cost when available.
13
+ - Telemetry is persisted as `tps` custom session entries and restored after session resume or `/tree` navigation.
14
+ - `/tps-export` exports telemetry JSONL; `/session-export` exports the session JSONL.
15
+
16
+ ## Migration from pi-tps
17
+
18
+ The TPS implementation is maintained in this package. Remove the standalone `npm:@monotykamary/pi-tps` entry from Pi settings before enabling this package, otherwise both extensions will record duplicate `tps` entries and notifications.
12
19
 
13
20
  ## Install
14
21
 
15
22
  ```bash
16
- pi install npm:pi-hud
23
+ pi install npm:pi-metrics
17
24
  ```
18
25
 
19
26
  ## How it works
20
27
 
21
28
  - The total timer starts on the `input` event (the moment you submit a message) and ends on `agent_settled`, so multi-turn tool calls, automatic retries, and queued continuations are all covered. Steer/follow-up messages sent mid-run do not reset the start point.
22
29
  - In non-TUI mode (rpc/print) the timer and notifications are disabled.
30
+ - The shared Neuralwatt cost listener is unsubscribed during `session_shutdown`, and deferred rehydration notifications are cancelled during reload/session changes.
23
31
 
24
32
  ## Localization
25
33
 
package/README.zh-CN.md CHANGED
@@ -1,6 +1,6 @@
1
- # pi-hud
1
+ # pi-metrics
2
2
 
3
- 面向 [Pi coding agent](https://github.com/earendil-works/pi) 的会话耗时抬头显示:working spinner 实时计时,并在对话流中给出每轮耗时与全程总耗时。
3
+ 面向 [Pi coding agent](https://github.com/earendil-works/pi) 的会话指标扩展:working spinner 实时计时,并提供每轮 TPS、TTFT 和 token 使用量。
4
4
 
5
5
  [English](./README.md)
6
6
 
@@ -9,17 +9,25 @@
9
9
  - 工作期间 spinner 显示**从发出消息起的全程耗时**(如 `⏱ 47s`),跨轮次持续累加,不再每轮回零。
10
10
  - 每个轮次结束时插入一条灰色文本,显示该轮精确耗时(`⏱ 本轮耗时 8.2s`)。
11
11
  - AI 完全停止时(`agent_settled`,覆盖自动重试、compaction 续跑以及 Esc 中断)追加一行**从发出消息到停止的总耗时**(`⏱ 总耗时 18.9s`)。
12
+ - 每轮 LLM 调用结束后显示 TPS、TTFT、token 数、生成耗时、stall 和可用的综合成本。
13
+ - Telemetry 以 `tps` custom session entry 持久化,并在恢复 session 或 `/tree` 后恢复显示。
14
+ - `/tps-export` 导出 TPS JSONL,`/session-export` 导出 session JSONL。
15
+
16
+ ## 从 pi-tps 迁移
17
+
18
+ TPS 实现现在由本包维护。启用本包前,请从 Pi 配置中移除独立的 `npm:@monotykamary/pi-tps`,否则两个扩展会重复写入 `tps` 条目并重复通知。
12
19
 
13
20
  ## 安装
14
21
 
15
22
  ```bash
16
- pi install npm:pi-hud
23
+ pi install npm:pi-metrics
17
24
  ```
18
25
 
19
26
  ## 实现说明
20
27
 
21
28
  - 总耗时以 `input` 事件(用户提交消息的时刻)为起点、`agent_settled` 为终点,因此多轮工具调用、自动重试和队列续跑都计入同一次总耗时;运行中发送的 steer/followUp 消息不会重置起点。
22
29
  - 非 TUI 模式(rpc/print)下不启动定时器、不发送通知。
30
+ - Neuralwatt 成本监听器会在 `session_shutdown` 时取消订阅,恢复通知的延迟定时器也会在 reload/session 切换时清理。
23
31
 
24
32
  ## 国际化
25
33
 
@@ -10,5 +10,61 @@
10
10
  "elapsedTotal": {
11
11
  "zh-CN": "⏱ 总耗时 {value}",
12
12
  "en-US": "⏱ Total elapsed {value}"
13
+ },
14
+ "tpsValue": {
15
+ "zh-CN": "TPS {value} tok/s",
16
+ "en-US": "TPS {value} tok/s"
17
+ },
18
+ "tpsUnknown": {
19
+ "zh-CN": "TPS —",
20
+ "en-US": "TPS —"
21
+ },
22
+ "tpsTtft": {
23
+ "zh-CN": "TTFT {value}",
24
+ "en-US": "TTFT {value}"
25
+ },
26
+ "tpsInput": {
27
+ "zh-CN": "in {value}",
28
+ "en-US": "in {value}"
29
+ },
30
+ "tpsOutput": {
31
+ "zh-CN": "out {value}",
32
+ "en-US": "out {value}"
33
+ },
34
+ "tpsStall": {
35
+ "zh-CN": "stall {value}×{count}",
36
+ "en-US": "stall {value}×{count}"
37
+ },
38
+ "tpsRate": {
39
+ "zh-CN": "${value}/M",
40
+ "en-US": "${value}/M"
41
+ },
42
+ "tpsExportDescription": {
43
+ "zh-CN": "导出 TPS telemetry JSONL(--full 导出整个 session)",
44
+ "en-US": "Export TPS telemetry as JSONL (--full for the full session)"
45
+ },
46
+ "sessionExportDescription": {
47
+ "zh-CN": "导出 session JSONL(--full 导出所有分支)",
48
+ "en-US": "Export session JSONL (--full for all branches)"
49
+ },
50
+ "tpsFullFlag": {
51
+ "zh-CN": "--full(导出所有分支)",
52
+ "en-US": "--full (all branches)"
53
+ },
54
+ "tpsNoEntries": {
55
+ "zh-CN": "在 {scope} 中没有匹配的条目",
56
+ "en-US": "No matching entries found in {scope}"
57
+ },
58
+ "tpsAllEntries": {
59
+ "zh-CN": "全部条目",
60
+ "en-US": "all entries"
61
+ },
62
+ "tpsCurrentBranch": {
63
+ "zh-CN": "当前分支",
64
+ "en-US": "current branch"
65
+ },
66
+ "tpsExported": {
67
+ "zh-CN": "已导出 {count} 个条目 → {filepath}",
68
+ "en-US": "Exported {count} entries → {filepath}"
13
69
  }
14
70
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-metrics",
3
- "version": "0.2.0",
4
- "description": "Pi metrics extension: live session elapsed timer with per-turn and total run summaries",
3
+ "version": "0.2.2",
4
+ "description": "Pi metrics extension: elapsed-time HUD and resilient TPS telemetry",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
7
7
  "exports": {
@@ -15,7 +15,7 @@
15
15
  "README.zh-CN.md"
16
16
  ],
17
17
  "scripts": {
18
- "test": "tsx --test tests/pi-metrics.test.ts",
18
+ "test": "tsx --test tests/*.test.ts",
19
19
  "typecheck": "tsc --noEmit --pretty false",
20
20
  "build": "npm run typecheck",
21
21
  "check": "npm run typecheck && npm test && npm pack --dry-run --json > /dev/null"
@@ -47,7 +47,9 @@
47
47
  "pi-extension",
48
48
  "coding-agent",
49
49
  "hud",
50
- "elapsed-time"
50
+ "elapsed-time",
51
+ "tokens-per-second",
52
+ "telemetry"
51
53
  ],
52
54
  "peerDependencies": {
53
55
  "@earendil-works/pi-ai": ">=0.80.0 <0.81.0",
package/src/index.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import turnElapsed from "./turn-elapsed.ts";
3
+ import tps from "./tps.ts";
3
4
 
4
5
  export default function piHud(pi: ExtensionAPI): void {
5
- // 后续 HUD 类功能(如 token 用量、上下文水位等)在此注册
6
6
  turnElapsed(pi);
7
+ tps(pi);
7
8
  }
8
9
 
9
10
  export { default as turnElapsed } from "./turn-elapsed.ts";
11
+ export { default as tps } from "./tps.ts";
10
12
  export * from "./format-utils.ts";
13
+ export * from "./tps.ts";
package/src/tps.ts ADDED
@@ -0,0 +1,624 @@
1
+ /**
2
+ * Token generation metrics for Pi.
3
+ *
4
+ * This is the TPS portion of pi-tps, maintained inside pi-metrics so the
5
+ * elapsed-time HUD and generation telemetry share one lifecycle.
6
+ */
7
+
8
+ import { execFileSync } from "node:child_process";
9
+ import { mkdirSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { performance } from "node:perf_hooks";
13
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
14
+ import type {
15
+ ExtensionAPI,
16
+ ExtensionCommandContext,
17
+ ExtensionContext,
18
+ } from "@earendil-works/pi-coding-agent";
19
+ import { i18n } from "./i18n.ts";
20
+
21
+ interface TurnStartEvent {
22
+ type: "turn_start";
23
+ turnIndex: number;
24
+ timestamp: number;
25
+ }
26
+
27
+ interface TurnEndEvent {
28
+ type: "turn_end";
29
+ turnIndex: number;
30
+ }
31
+
32
+ interface MessageEvent {
33
+ type: string;
34
+ message: unknown;
35
+ }
36
+
37
+ interface SessionTreeEvent {
38
+ type: "session_tree";
39
+ newLeafId: string | null;
40
+ oldLeafId: string | null;
41
+ }
42
+
43
+ interface ToolExecutionStartEvent {
44
+ type: "tool_execution_start";
45
+ toolCallId: string;
46
+ toolName: string;
47
+ args: unknown;
48
+ }
49
+
50
+ export interface TurnTelemetry {
51
+ model: { provider: string; modelId: string };
52
+ tokens: {
53
+ input: number;
54
+ output: number;
55
+ cacheRead: number;
56
+ cacheWrite: number;
57
+ total: number;
58
+ };
59
+ timing: {
60
+ ttftMs: number | null;
61
+ totalMs: number;
62
+ generationMs: number;
63
+ streamMs: number | null;
64
+ stallMs: number;
65
+ stallCount: number;
66
+ messageCount: number;
67
+ };
68
+ tps: number | null;
69
+ isPrimaryBranch: boolean;
70
+ cost: {
71
+ input: number;
72
+ output: number;
73
+ cacheRead: number;
74
+ cacheWrite: number;
75
+ total: number;
76
+ } | null;
77
+ rateUsdPerMTokens: number | null;
78
+ timestamp: number;
79
+ }
80
+
81
+ interface TurnTiming {
82
+ turnIndex: number;
83
+ turnStartMs: number;
84
+ turnStartTimestamp: number;
85
+ lastUpdateMs: number;
86
+ firstTokenMs: number | null;
87
+ currentMessageStartMs: number | null;
88
+ assistantMessages: AssistantMessage[];
89
+ totalGenerationMs: number;
90
+ updateCount: number;
91
+ firstStreamUpdateMs: number | null;
92
+ lastStreamUpdateMs: number;
93
+ stallMs: number;
94
+ stallCount: number;
95
+ inStall: boolean;
96
+ messageCount: number;
97
+ isToolCall: boolean;
98
+ isPrimaryBranch: boolean;
99
+ }
100
+
101
+ interface SessionEntryLike {
102
+ id: string;
103
+ parentId?: string | null;
104
+ type: string;
105
+ customType?: string;
106
+ data?: unknown;
107
+ timestamp?: number | string;
108
+ [key: string]: unknown;
109
+ }
110
+
111
+ const STALL_THRESHOLD_MS = 500;
112
+ const NEURALWATT_ENERGY_EVENT = "neuralwatt:turn-energy";
113
+
114
+ export function formatNumber(num: number): string {
115
+ if (num < 1_000) return String(num);
116
+
117
+ const [value, suffix] = num >= 1_000_000_000
118
+ ? [num / 1_000_000_000, "B"]
119
+ : num >= 1_000_000
120
+ ? [num / 1_000_000, "M"]
121
+ : [num / 1_000, "K"];
122
+ const formatted = value.toFixed(1);
123
+ return formatted.endsWith(".0") ? `${value.toFixed(0)}${suffix}` : `${formatted}${suffix}`;
124
+ }
125
+
126
+ export function formatDuration(totalSeconds: number): string {
127
+ if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`;
128
+
129
+ const units = [
130
+ ["y", 365 * 24 * 60 * 60],
131
+ ["mo", 30 * 24 * 60 * 60],
132
+ ["w", 7 * 24 * 60 * 60],
133
+ ["d", 24 * 60 * 60],
134
+ ["h", 60 * 60],
135
+ ["m", 60],
136
+ ["s", 1],
137
+ ] as const;
138
+ const parts: Array<{ value: number; label: string }> = [];
139
+ let remaining = Math.round(totalSeconds);
140
+
141
+ for (const [label, seconds] of units) {
142
+ if (remaining >= seconds) {
143
+ parts.push({ value: Math.floor(remaining / seconds), label });
144
+ remaining %= seconds;
145
+ }
146
+ }
147
+
148
+ if (parts.length === 1) {
149
+ const first = units.findIndex(([label]) => label === parts[0].label);
150
+ let next = first + 1;
151
+ if (parts[0].label === "mo") next++;
152
+ if (parts[0].label === "y") next += 2;
153
+ if (next < units.length) parts.push({ value: 0, label: units[next][0] });
154
+ }
155
+
156
+ return parts.slice(0, 2).map(({ value, label }) => `${value}${label}`).join(" ");
157
+ }
158
+
159
+ export function computeRateUsdPerM(costUsd: number | null, totalTokens: number): number | null {
160
+ if (costUsd === null || !Number.isFinite(costUsd) || costUsd < 0) return null;
161
+ if (!Number.isFinite(totalTokens) || totalTokens <= 0) return null;
162
+ const rate = costUsd / (totalTokens / 1_000_000);
163
+ return Number.isFinite(rate) && rate >= 0 ? Math.round(rate * 100) / 100 : null;
164
+ }
165
+
166
+ function isAssistantMessage(message: unknown): message is AssistantMessage {
167
+ if (!message || typeof message !== "object") return false;
168
+ const candidate = message as Record<string, unknown>;
169
+ if (candidate.role !== "assistant" || typeof candidate.usage !== "object" || candidate.usage === null) {
170
+ return false;
171
+ }
172
+ const usage = candidate.usage as Record<string, unknown>;
173
+ return typeof usage.input === "number" && typeof usage.output === "number";
174
+ }
175
+
176
+ function findEnergyCostFromSession(ctx: ExtensionContext, turnStartTimestamp: number): number | null {
177
+ const entries = ctx.sessionManager?.getEntries?.() as SessionEntryLike[] | undefined;
178
+ if (!entries) return null;
179
+
180
+ for (let i = entries.length - 1; i >= 0; i--) {
181
+ const entry = entries[i];
182
+ if (entry.type !== "custom" || entry.customType !== "neuralwatt-energy") continue;
183
+ const entryTimestamp = parseEntryTimestamp(entry.timestamp);
184
+ if (Number.isFinite(entryTimestamp) && entryTimestamp < turnStartTimestamp) return null;
185
+ const data = entry.data as Record<string, unknown> | null | undefined;
186
+ const cost = data?.cost_usd;
187
+ if (typeof cost === "number" && Number.isFinite(cost) && cost >= 0) return cost;
188
+ }
189
+ return null;
190
+ }
191
+
192
+ function parseEntryTimestamp(value: number | string | undefined): number {
193
+ if (typeof value === "number") return value;
194
+ if (typeof value !== "string") return Number.NaN;
195
+ const numeric = Number(value);
196
+ return Number.isFinite(numeric) ? numeric : Date.parse(value);
197
+ }
198
+
199
+ function buildTelemetry(
200
+ timing: TurnTiming,
201
+ turnEndMs: number,
202
+ billedCost: number | null,
203
+ ): TurnTelemetry | null {
204
+ let input = 0;
205
+ let output = 0;
206
+ let cacheRead = 0;
207
+ let cacheWrite = 0;
208
+ let totalTokens = 0;
209
+ let costInput = 0;
210
+ let costOutput = 0;
211
+ let costCacheRead = 0;
212
+ let costCacheWrite = 0;
213
+ let costTotal = 0;
214
+ let hasCost = false;
215
+ let model: { provider: string; modelId: string } | null = null;
216
+
217
+ for (const message of timing.assistantMessages) {
218
+ const usage = message.usage;
219
+ input += usage.input || 0;
220
+ output += usage.output || 0;
221
+ cacheRead += usage.cacheRead || 0;
222
+ cacheWrite += usage.cacheWrite || 0;
223
+ totalTokens += usage.totalTokens || 0;
224
+ if (usage.cost) {
225
+ costInput += usage.cost.input || 0;
226
+ costOutput += usage.cost.output || 0;
227
+ costCacheRead += usage.cost.cacheRead || 0;
228
+ costCacheWrite += usage.cost.cacheWrite || 0;
229
+ costTotal += usage.cost.total || 0;
230
+ hasCost = true;
231
+ }
232
+ if (!model && message.provider && message.model) {
233
+ model = { provider: message.provider, modelId: message.model };
234
+ }
235
+ }
236
+
237
+ if (output <= 0 || timing.firstTokenMs === null || !model) return null;
238
+
239
+ const totalMs = turnEndMs - timing.turnStartMs;
240
+ const streamMs = timing.updateCount > 0 && timing.firstStreamUpdateMs !== null
241
+ ? timing.lastStreamUpdateMs - timing.firstStreamUpdateMs
242
+ : null;
243
+ const averageGap = streamMs !== null && timing.updateCount > 1
244
+ ? streamMs / (timing.updateCount - 1)
245
+ : 0;
246
+
247
+ const primary =
248
+ streamMs !== null &&
249
+ streamMs >= 1 &&
250
+ timing.updateCount >= 5 &&
251
+ averageGap >= 1 &&
252
+ timing.stallMs < streamMs &&
253
+ streamMs - timing.stallMs >= 200 &&
254
+ timing.stallMs < streamMs - timing.stallMs;
255
+
256
+ let tps: number | null;
257
+ let isPrimaryBranch = false;
258
+ if (primary) {
259
+ tps = Math.round((output / ((streamMs! - timing.stallMs) / 1000)) * 10) / 10;
260
+ isPrimaryBranch = true;
261
+ } else if (timing.updateCount >= 2 && timing.totalGenerationMs >= 200) {
262
+ let effectiveMs = timing.totalGenerationMs - timing.stallMs;
263
+ if (effectiveMs < 200 || timing.stallMs > timing.totalGenerationMs * 0.85) {
264
+ effectiveMs = Math.max(timing.totalGenerationMs - timing.stallMs / 2, 200);
265
+ } else {
266
+ effectiveMs = Math.max(effectiveMs, 200);
267
+ }
268
+ tps = Math.round((output / (effectiveMs / 1000)) * 10) / 10;
269
+ } else {
270
+ tps = null;
271
+ }
272
+
273
+ if (tps !== null && tps > 10_000) {
274
+ tps = null;
275
+ isPrimaryBranch = false;
276
+ }
277
+
278
+ const listPriceCost = hasCost && Number.isFinite(costTotal) && costTotal > 0 ? costTotal : null;
279
+ const effectiveCost = billedCost ?? listPriceCost;
280
+ return {
281
+ model,
282
+ tokens: { input, output, cacheRead, cacheWrite, total: totalTokens },
283
+ timing: {
284
+ ttftMs: timing.firstTokenMs - timing.turnStartMs,
285
+ totalMs,
286
+ generationMs: timing.totalGenerationMs,
287
+ streamMs,
288
+ stallMs: timing.stallMs,
289
+ stallCount: timing.stallCount,
290
+ messageCount: timing.messageCount,
291
+ },
292
+ tps,
293
+ isPrimaryBranch,
294
+ cost: listPriceCost === null
295
+ ? null
296
+ : { input: costInput, output: costOutput, cacheRead: costCacheRead, cacheWrite: costCacheWrite, total: costTotal },
297
+ rateUsdPerMTokens: computeRateUsdPerM(effectiveCost, totalTokens),
298
+ timestamp: Date.now(),
299
+ };
300
+ }
301
+
302
+ function composeDisplayString(telemetry: TurnTelemetry): string {
303
+ const parts = [
304
+ telemetry.tps === null
305
+ ? i18n.t("tpsUnknown")
306
+ : i18n.t("tpsValue", { value: telemetry.tps.toFixed(1) }),
307
+ ];
308
+ if (telemetry.timing.ttftMs !== null) {
309
+ parts.push(i18n.t("tpsTtft", { value: formatDuration(telemetry.timing.ttftMs / 1000) }));
310
+ }
311
+ parts.push(formatDuration(telemetry.timing.totalMs / 1000));
312
+ parts.push(i18n.t("tpsInput", { value: formatNumber(telemetry.tokens.input) }));
313
+ parts.push(i18n.t("tpsOutput", { value: formatNumber(telemetry.tokens.output) }));
314
+ if (telemetry.timing.stallMs > 0) {
315
+ parts.push(i18n.t("tpsStall", {
316
+ value: formatDuration(telemetry.timing.stallMs / 1000),
317
+ count: telemetry.timing.stallCount,
318
+ }));
319
+ }
320
+ if (telemetry.rateUsdPerMTokens !== null) {
321
+ parts.push(i18n.t("tpsRate", { value: telemetry.rateUsdPerMTokens.toFixed(2) }));
322
+ }
323
+ return parts.join(" · ");
324
+ }
325
+
326
+ function restoreTPSNotification(
327
+ ctx: ExtensionContext,
328
+ schedule: (callback: () => void) => void,
329
+ ): void {
330
+ if (!ctx.hasUI) return;
331
+ const entries = ctx.sessionManager.getBranch() as SessionEntryLike[];
332
+ for (let i = entries.length - 1; i >= 0; i--) {
333
+ const entry = entries[i];
334
+ if (entry.type !== "custom" || entry.customType !== "tps") continue;
335
+ const data = entry.data as Record<string, unknown> | null | undefined;
336
+ if (!data) continue;
337
+ if (typeof data.model === "object" && data.model !== null) {
338
+ schedule(() => ctx.ui.notify(composeDisplayString(data as unknown as TurnTelemetry), "info"));
339
+ return;
340
+ }
341
+ if (typeof data.message === "string") {
342
+ schedule(() => ctx.ui.notify(data.message as string, "info"));
343
+ return;
344
+ }
345
+ }
346
+ }
347
+
348
+ function openDirectory(directory: string): void {
349
+ try {
350
+ execFileSync(process.platform === "darwin" ? "open" : "xdg-open", [directory], {
351
+ stdio: "ignore",
352
+ });
353
+ } catch {
354
+ // Opening an export directory is optional; the path is still reported.
355
+ }
356
+ }
357
+
358
+ function exportEntries(
359
+ entries: SessionEntryLike[],
360
+ full: boolean,
361
+ filterType: string | null,
362
+ directoryName: "pi-telemetry" | "pi-sessions",
363
+ prefix: "pi-telemetry" | "pi-session",
364
+ sessionId: string,
365
+ ): { filepath: string; count: number } {
366
+ const isStructural = (entry: SessionEntryLike) =>
367
+ entry.type === "model_change" || entry.type === "branch_summary";
368
+ const exported = entries.filter(
369
+ (entry) => isStructural(entry) ||
370
+ (entry.type === "custom" && (!filterType || entry.customType === filterType)),
371
+ );
372
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
373
+ const exportedIds = new Set(exported.map((entry) => entry.id));
374
+ const rechainParentId = (entry: SessionEntryLike): string | null => {
375
+ let parentId = entry.parentId ?? null;
376
+ while (parentId) {
377
+ if (exportedIds.has(parentId)) return parentId;
378
+ parentId = byId.get(parentId)?.parentId ?? null;
379
+ }
380
+ return null;
381
+ };
382
+ const rechained = exported.map((entry) => ({ ...entry, parentId: rechainParentId(entry) }));
383
+ const cacheBase = process.env.XDG_CACHE_HOME || join(homedir(), ".cache");
384
+ const directory = join(cacheBase, directoryName);
385
+ mkdirSync(directory, { recursive: true });
386
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
387
+ const scope = [full ? "full" : "branch", filterType?.replace(/[^a-zA-Z0-9_-]/g, "-")]
388
+ .filter(Boolean)
389
+ .join("-");
390
+ const filepath = join(directory, `${prefix}-${scope}-${sessionId.slice(0, 8)}-${timestamp}.jsonl`);
391
+ writeFileSync(filepath, `${rechained.map((entry) => JSON.stringify(entry)).join("\n")}\n`);
392
+ openDirectory(directory);
393
+ return { filepath, count: exported.length };
394
+ }
395
+
396
+ export default function tpsExtension(pi: ExtensionAPI): void {
397
+ let currentTiming: TurnTiming | null = null;
398
+ let pendingNeuralwattBilledCost: { turnIndex: number; costUsd: number } | null = null;
399
+ let lastCommittedTurn: {
400
+ turnIndex: number;
401
+ telemetry: TurnTelemetry;
402
+ billedApplied: boolean;
403
+ ctx: ExtensionContext;
404
+ } | null = null;
405
+ let cachedEntries: Array<{ type?: string; customType?: string }> = [];
406
+ const tpsCaps = new Map<string, number>();
407
+ const restoreTimers = new Set<ReturnType<typeof setTimeout>>();
408
+ let unsubscribeNeuralwatt: (() => void) | undefined;
409
+
410
+ const clearState = () => {
411
+ currentTiming = null;
412
+ pendingNeuralwattBilledCost = null;
413
+ lastCommittedTurn = null;
414
+ cachedEntries = [];
415
+ for (const timer of restoreTimers) clearTimeout(timer);
416
+ restoreTimers.clear();
417
+ };
418
+
419
+ const scheduleRestore = (callback: () => void) => {
420
+ const timer = setTimeout(() => {
421
+ restoreTimers.delete(timer);
422
+ callback();
423
+ }, 0);
424
+ restoreTimers.add(timer);
425
+ };
426
+
427
+ unsubscribeNeuralwatt = pi.events?.on(NEURALWATT_ENERGY_EVENT, (payload: unknown) => {
428
+ if (!payload || typeof payload !== "object") return;
429
+ const data = payload as Record<string, unknown>;
430
+ const turnIndex = typeof data.turnIndex === "number" ? data.turnIndex : null;
431
+ const costUsd = typeof data.costUsd === "number" ? data.costUsd : null;
432
+ if (turnIndex === null || costUsd === null || !Number.isFinite(costUsd) || costUsd < 0) return;
433
+
434
+ if (currentTiming) {
435
+ if (currentTiming.turnIndex === turnIndex) {
436
+ pendingNeuralwattBilledCost = { turnIndex, costUsd };
437
+ }
438
+ return;
439
+ }
440
+
441
+ const committed = lastCommittedTurn;
442
+ if (!committed || committed.billedApplied || committed.turnIndex !== turnIndex) return;
443
+ committed.billedApplied = true;
444
+ const correctedRate = computeRateUsdPerM(costUsd, committed.telemetry.tokens.total);
445
+ if (correctedRate === null || correctedRate === committed.telemetry.rateUsdPerMTokens) return;
446
+ const corrected = { ...committed.telemetry, rateUsdPerMTokens: correctedRate };
447
+ committed.telemetry = corrected;
448
+ pi.appendEntry("tps", corrected);
449
+ pi.events?.emit("tps:telemetry", corrected);
450
+ cachedEntries.push({ type: "custom", customType: "tps" });
451
+ if (committed.ctx.hasUI) committed.ctx.ui.notify(composeDisplayString(corrected), "info");
452
+ });
453
+
454
+ pi.on("session_shutdown", () => {
455
+ unsubscribeNeuralwatt?.();
456
+ unsubscribeNeuralwatt = undefined;
457
+ clearState();
458
+ });
459
+
460
+ pi.on("session_start", (_event, ctx) => {
461
+ clearState();
462
+ cachedEntries = ctx.sessionManager.getEntries();
463
+ restoreTPSNotification(ctx, scheduleRestore);
464
+ });
465
+
466
+ pi.on("session_tree", (_event: SessionTreeEvent, ctx) => {
467
+ pendingNeuralwattBilledCost = null;
468
+ lastCommittedTurn = null;
469
+ cachedEntries = ctx.sessionManager.getEntries();
470
+ restoreTPSNotification(ctx, scheduleRestore);
471
+ });
472
+
473
+ pi.on("turn_start", (event: TurnStartEvent) => {
474
+ pendingNeuralwattBilledCost = null;
475
+ lastCommittedTurn = null;
476
+ currentTiming = {
477
+ turnIndex: event.turnIndex,
478
+ turnStartMs: performance.now(),
479
+ turnStartTimestamp: typeof event.timestamp === "number" ? event.timestamp : Date.now(),
480
+ lastUpdateMs: performance.now(),
481
+ firstTokenMs: null,
482
+ currentMessageStartMs: null,
483
+ assistantMessages: [],
484
+ totalGenerationMs: 0,
485
+ updateCount: 0,
486
+ firstStreamUpdateMs: null,
487
+ lastStreamUpdateMs: 0,
488
+ stallMs: 0,
489
+ stallCount: 0,
490
+ inStall: false,
491
+ messageCount: 0,
492
+ isToolCall: false,
493
+ isPrimaryBranch: false,
494
+ };
495
+ });
496
+
497
+ pi.on("message_start", (event: MessageEvent) => {
498
+ if (!currentTiming || !isAssistantMessage(event.message)) return;
499
+ const now = performance.now();
500
+ currentTiming.currentMessageStartMs = now;
501
+ currentTiming.messageCount++;
502
+ currentTiming.lastUpdateMs = now;
503
+ currentTiming.inStall = false;
504
+ });
505
+
506
+ pi.on("message_update", (event: MessageEvent) => {
507
+ if (!currentTiming || !isAssistantMessage(event.message)) return;
508
+ const now = performance.now();
509
+ if (currentTiming.firstTokenMs === null) {
510
+ currentTiming.firstTokenMs = now;
511
+ currentTiming.lastUpdateMs = now;
512
+ return;
513
+ }
514
+
515
+ currentTiming.updateCount++;
516
+ if (currentTiming.firstStreamUpdateMs === null) currentTiming.firstStreamUpdateMs = now;
517
+ currentTiming.lastStreamUpdateMs = now;
518
+ const gap = now - currentTiming.lastUpdateMs;
519
+ if (gap >= STALL_THRESHOLD_MS) {
520
+ if (!currentTiming.inStall) currentTiming.stallCount++;
521
+ currentTiming.inStall = true;
522
+ currentTiming.stallMs += gap;
523
+ } else {
524
+ currentTiming.inStall = false;
525
+ }
526
+ currentTiming.lastUpdateMs = now;
527
+ });
528
+
529
+ pi.on("tool_execution_start", (_event: ToolExecutionStartEvent) => {
530
+ if (currentTiming) currentTiming.isToolCall = true;
531
+ });
532
+
533
+ pi.on("message_end", (event: MessageEvent) => {
534
+ if (!currentTiming || !isAssistantMessage(event.message)) return;
535
+ const now = performance.now();
536
+ if (currentTiming.currentMessageStartMs !== null) {
537
+ currentTiming.totalGenerationMs += now - currentTiming.currentMessageStartMs;
538
+ currentTiming.currentMessageStartMs = null;
539
+ }
540
+ currentTiming.assistantMessages.push(event.message);
541
+ currentTiming.lastUpdateMs = now;
542
+ });
543
+
544
+ pi.on("turn_end", (event: TurnEndEvent, ctx: ExtensionContext) => {
545
+ if (!currentTiming) return;
546
+ const timing = currentTiming;
547
+ currentTiming = null;
548
+ let billedCost = pendingNeuralwattBilledCost?.turnIndex === event.turnIndex
549
+ ? pendingNeuralwattBilledCost.costUsd
550
+ : null;
551
+ pendingNeuralwattBilledCost = null;
552
+ if (billedCost === null) billedCost = findEnergyCostFromSession(ctx, timing.turnStartTimestamp);
553
+ const telemetry = buildTelemetry(timing, performance.now(), billedCost);
554
+ if (!telemetry) return;
555
+
556
+ const modelKey = `${telemetry.model.provider}:${telemetry.model.modelId}`;
557
+ if (telemetry.isPrimaryBranch && telemetry.tps !== null) {
558
+ const currentCap = tpsCaps.get(modelKey);
559
+ if (currentCap === undefined || telemetry.tps > currentCap) tpsCaps.set(modelKey, telemetry.tps);
560
+ }
561
+ if (timing.isToolCall && telemetry.tps !== null) {
562
+ const cap = tpsCaps.get(modelKey);
563
+ telemetry.tps = cap === undefined ? null : Math.min(telemetry.tps, cap);
564
+ }
565
+
566
+ lastCommittedTurn = {
567
+ turnIndex: event.turnIndex,
568
+ telemetry,
569
+ billedApplied: billedCost !== null,
570
+ ctx,
571
+ };
572
+ pi.appendEntry("tps", telemetry);
573
+ pi.events?.emit("tps:telemetry", telemetry);
574
+ if (ctx.hasUI) ctx.ui.notify(composeDisplayString(telemetry), "info");
575
+ cachedEntries.push({ type: "custom", customType: "tps" });
576
+ });
577
+
578
+ pi.registerCommand("tps-export", {
579
+ description: i18n.t("tpsExportDescription"),
580
+ getArgumentCompletions: (argumentPrefix: string) => {
581
+ if ("--full".startsWith(argumentPrefix)) return [{ value: "--full", label: i18n.t("tpsFullFlag") }];
582
+ const types = new Set<string>();
583
+ for (const entry of cachedEntries) {
584
+ if (entry.type === "custom" && entry.customType) types.add(entry.customType);
585
+ }
586
+ return [...types]
587
+ .filter((customType) => customType.startsWith(argumentPrefix))
588
+ .map((customType) => ({ value: customType, label: customType }));
589
+ },
590
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
591
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
592
+ const full = tokens.includes("--full");
593
+ const filterType = tokens.filter((token) => token !== "--full").join(" ") || null;
594
+ const entries = (full ? ctx.sessionManager.getEntries() : ctx.sessionManager.getBranch()) as SessionEntryLike[];
595
+ const isStructural = (entry: SessionEntryLike) => entry.type === "model_change" || entry.type === "branch_summary";
596
+ const matching = entries.filter((entry) => isStructural(entry) ||
597
+ (entry.type === "custom" && (!filterType || entry.customType === filterType)));
598
+ if (matching.length === 0) {
599
+ ctx.ui.notify(i18n.t("tpsNoEntries", { scope: full ? i18n.t("tpsAllEntries") : i18n.t("tpsCurrentBranch") }), "warning");
600
+ return;
601
+ }
602
+ const sessionId = ctx.sessionManager.getSessionId?.() ?? "unknown";
603
+ const result = exportEntries(entries, full, filterType, "pi-telemetry", "pi-telemetry", sessionId);
604
+ ctx.ui.notify(i18n.t("tpsExported", { count: result.count, filepath: result.filepath }), "info");
605
+ },
606
+ });
607
+
608
+ pi.registerCommand("session-export", {
609
+ description: i18n.t("sessionExportDescription"),
610
+ getArgumentCompletions: (argumentPrefix: string) =>
611
+ "--full".startsWith(argumentPrefix) ? [{ value: "--full", label: i18n.t("tpsFullFlag") }] : [],
612
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
613
+ const full = args.trim().split(/\s+/).includes("--full");
614
+ const entries = (full ? ctx.sessionManager.getEntries() : ctx.sessionManager.getBranch()) as SessionEntryLike[];
615
+ if (entries.length === 0) {
616
+ ctx.ui.notify(i18n.t("tpsNoEntries", { scope: full ? i18n.t("tpsAllEntries") : i18n.t("tpsCurrentBranch") }), "warning");
617
+ return;
618
+ }
619
+ const sessionId = ctx.sessionManager.getSessionId?.() ?? "unknown";
620
+ const result = exportEntries(entries, full, null, "pi-sessions", "pi-session", sessionId);
621
+ ctx.ui.notify(i18n.t("tpsExported", { count: result.count, filepath: result.filepath }), "info");
622
+ },
623
+ });
624
+ }