@smoose/pi-tps 0.0.1

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.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +101 -0
  3. package/index.ts +84 -0
  4. package/metrics.ts +104 -0
  5. package/package.json +56 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 smoose
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, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # pi-tps
2
+
3
+ Decode throughput for pi: how fast the model is actually generating, in the footer.
4
+
5
+ `20 tok/s` appears once an assistant message settles, and keeps running for the
6
+ session.
7
+
8
+ ## What it measures
9
+
10
+ ```
11
+ tps = Σ provider output tokens / (Σ decode wall time / 1000)
12
+ decode wall time = first output token → settled message
13
+ ```
14
+
15
+ The sums run over the most recent 200 settled messages, so a normal session is
16
+ cumulative and a long one becomes a rolling readout.
17
+
18
+ - **Decode only.** TTFT, queueing, tool execution and retry waits stay out of the
19
+ denominator, so this reads lower than an end-to-end tokens/second figure.
20
+ - **Provider tokens.** The numerator is the provider's reported `usage.output`,
21
+ never a count of stream deltas. A message with no reported usage is skipped, not
22
+ estimated.
23
+ - **Time-weighted.** Windows are summed before dividing; this is not an average of
24
+ per-message rates, so a slow answer weighs more than a fast one.
25
+ - **First token means real output.** `text_delta`, `thinking_delta` or
26
+ `toolcall_delta` carrying a non-empty fragment, or the start of a tool call —
27
+ the last one counts so a tool call with no argument deltas still gets a
28
+ first-token time instead of being dropped.
29
+ - **Cancelled and failed calls are excluded.** They settle as messages but
30
+ assembled no response, so they contribute nothing to the window.
31
+ - **Settled only.** The figure never moves mid-stream; a displayed number is
32
+ always a complete window.
33
+ - **One attempt per turn.** pi restarts a turn when it auto-retries, so only the
34
+ attempt that produced the message is measured.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pi install npm:@smoose/pi-tps
40
+ ```
41
+
42
+ Or from a local checkout — point settings at the directory:
43
+
44
+ ```json
45
+ { "extensions": ["/path/to/pi-tps"] }
46
+ ```
47
+
48
+ or symlink it into the auto-discovered directory (`/reload` picks it up):
49
+
50
+ ```bash
51
+ ln -s /path/to/pi-tps ~/.pi/agent/extensions/pi-tps
52
+ ```
53
+
54
+ Or run a package once without installing it:
55
+
56
+ ```bash
57
+ pi -e npm:@smoose/pi-tps
58
+ ```
59
+
60
+ ## Footer wiring
61
+
62
+ The extension publishes the `tps` status key. With `@smoose/pi-footer`, add a
63
+ custom item and a segment to `~/.pi/agent/settings.json`:
64
+
65
+ ```json
66
+ {
67
+ "footer": {
68
+ "segments": ["model", "thinking", "path", "git", "context_pct", "custom:tps", "cost"],
69
+ "customItems": [
70
+ { "id": "tps", "statusKey": "tps", "prefix": "TPS", "color": "accent", "hideWhenMissing": true }
71
+ ]
72
+ }
73
+ }
74
+ ```
75
+
76
+ `hideWhenMissing: true` keeps the slot empty until the first message settles.
77
+ Without a footer extension the key is simply unused; nothing breaks.
78
+
79
+ ## State
80
+
81
+ In memory only. Nothing is written to the session: no custom entries, nothing in
82
+ the transcript, nothing in `/tree`. The window is cleared when:
83
+
84
+ - the process restarts, or `/reload`, `/new`, `/resume`, `/fork` recreates the
85
+ session or the extension;
86
+ - the model changes — decode speed is a property of the model, and blending two
87
+ models' samples would make the figure meaningless. Selecting the same model
88
+ again does not clear it.
89
+
90
+ `/tree` navigation deliberately does not clear it: rewinding is not a model
91
+ change and those tokens were still generated. Auto-compaction does not clear it
92
+ either.
93
+
94
+ The window holds the 200 most recent samples, and the oldest drop out beyond that.
95
+
96
+ ## Development
97
+
98
+ ```bash
99
+ bun install
100
+ bun run typecheck
101
+ ```
package/index.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * pi-tps — decode throughput in the footer.
3
+ *
4
+ * The measurement is deepseek-harness's: provider-reported output tokens over the
5
+ * decode wall time only (first output token → settled message), blended over the
6
+ * most recent WINDOW_SIZE settled messages. It is settled-only by design — the
7
+ * figure updates when an assistant message lands and never moves mid-stream, so a
8
+ * displayed value is always a complete window rather than a running guess.
9
+ *
10
+ * State is in-memory: nothing is written to the session, so /tree stays clean and
11
+ * a restarted process starts over.
12
+ */
13
+
14
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import {
16
+ formatTokensPerSecond,
17
+ isTokenDelta,
18
+ outputTokens,
19
+ pushSample,
20
+ tokensPerSecond,
21
+ type DecodeSample,
22
+ } from "./metrics.ts";
23
+
24
+ /** Footer status key. Point a footer custom item's `statusKey` at it. */
25
+ const STATUS_KEY = "tps";
26
+
27
+ export default function (pi: ExtensionAPI) {
28
+ let samples: DecodeSample[] = [];
29
+ let firstTokenAt: number | null = null;
30
+
31
+ function publish(ctx: ExtensionContext): void {
32
+ const value = tokensPerSecond(samples);
33
+ ctx.ui.setStatus(STATUS_KEY, value === undefined ? undefined : formatTokensPerSecond(value));
34
+ }
35
+
36
+ /** Drop the window and blank the footer slot: the old figure describes work that no longer applies. */
37
+ function reset(ctx: ExtensionContext): void {
38
+ samples = [];
39
+ firstTokenAt = null;
40
+ ctx.ui.setStatus(STATUS_KEY, undefined);
41
+ }
42
+
43
+ // A restarted, resumed, forked or newly created session is a different
44
+ // conversation than whatever this process was counting.
45
+ pi.on("session_start", (_event, ctx) => {
46
+ reset(ctx);
47
+ });
48
+
49
+ // Decode speed is a property of the model; mixing two models' samples makes the
50
+ // figure meaningless. /tree navigation is not a model change and keeps the window.
51
+ pi.on("model_select", (_event, ctx) => {
52
+ reset(ctx);
53
+ });
54
+
55
+ pi.on("turn_start", () => {
56
+ firstTokenAt = null;
57
+ });
58
+
59
+ pi.on("message_update", (event) => {
60
+ if (firstTokenAt !== null) return;
61
+ if (event.message.role !== "assistant") return;
62
+ if (!isTokenDelta(event.assistantMessageEvent)) return;
63
+ firstTokenAt = Date.now();
64
+ });
65
+
66
+ pi.on("message_end", (event, ctx) => {
67
+ if (event.message.role !== "assistant") return;
68
+ const started = firstTokenAt;
69
+ firstTokenAt = null;
70
+ const { stopReason, usage } = event.message;
71
+ // A cancelled or failed call settles a message here, but it assembled no
72
+ // response; the harness excludes those windows and so does this window.
73
+ if (stopReason === "error" || stopReason === "aborted") return;
74
+ if (started === null) return;
75
+ const tokens = outputTokens(usage);
76
+ if (tokens === undefined) return;
77
+ samples = pushSample(samples, { decodeMs: Math.max(0, Date.now() - started), outputTokens: tokens });
78
+ publish(ctx);
79
+ });
80
+
81
+ pi.on("session_shutdown", (_event, ctx) => {
82
+ reset(ctx);
83
+ });
84
+ }
package/metrics.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Decode-throughput window, ported from deepseek-harness's `turn-metrics` /
3
+ * `sessionStats` projection.
4
+ *
5
+ * Throughput is provider-reported output tokens over the decode wall time alone:
6
+ * first output token → settled message. TTFT, queueing, tool execution and
7
+ * retry waits stay outside the denominator on purpose. The figure is a
8
+ * time-weighted blend over the retained samples, never an average of per-message
9
+ * rates.
10
+ */
11
+
12
+ import type { AssistantMessageEvent } from "@earendil-works/pi-ai";
13
+
14
+ /** Samples retained for the figure: the most recent N settled messages. */
15
+ export const WINDOW_SIZE = 200;
16
+
17
+ /** One settled message's decode window and provider-reported output tokens. */
18
+ export interface DecodeSample {
19
+ /** First output token → settled message, ms. */
20
+ decodeMs: number
21
+ /** Provider-reported output tokens over that window. */
22
+ outputTokens: number
23
+ }
24
+
25
+ function isCount(value: unknown): value is number {
26
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
27
+ }
28
+
29
+ /**
30
+ * Add one sample, dropping the oldest once the window is full.
31
+ * @param samples - the samples retained so far, oldest first.
32
+ * @param sample - one message's decode window and output tokens.
33
+ * @param capacity - samples to retain.
34
+ * @returns a new window; the input is never mutated.
35
+ */
36
+ export function pushSample(
37
+ samples: readonly DecodeSample[],
38
+ sample: DecodeSample,
39
+ capacity: number = WINDOW_SIZE,
40
+ ): DecodeSample[] {
41
+ const next = [...samples, sample];
42
+ return next.length > capacity ? next.slice(next.length - capacity) : next;
43
+ }
44
+
45
+ /**
46
+ * Throughput over the retained samples.
47
+ * @param samples - the window, oldest first.
48
+ * @returns tokens per second, or undefined while the window holds no decode time.
49
+ */
50
+ export function tokensPerSecond(samples: readonly DecodeSample[]): number | undefined {
51
+ let decodeMs = 0;
52
+ let outputTokens = 0;
53
+ for (const sample of samples) {
54
+ decodeMs += sample.decodeMs;
55
+ outputTokens += sample.outputTokens;
56
+ }
57
+ if (decodeMs <= 0) return undefined;
58
+ return outputTokens / (decodeMs / 1000);
59
+ }
60
+
61
+ /**
62
+ * Display figure: whole tokens from ten up, one decimal below.
63
+ * @param tps - tokens per second.
64
+ * @returns the number with its unit, e.g. `20 tok/s`.
65
+ */
66
+ export function formatTokensPerSecond(tps: number): string {
67
+ const clamped = Math.max(0, tps);
68
+ const value = clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10);
69
+ return `${value} tok/s`;
70
+ }
71
+
72
+ /**
73
+ * Whether one stream event carries model-emitted output, i.e. the model has
74
+ * started decoding. Block/usage/finish boundaries do not qualify, and an empty
75
+ * fragment is whitespace-only noise rather than a token. `toolcall_start`
76
+ * counts because a tool call with no argument deltas would otherwise leave a
77
+ * response with no first-token time at all.
78
+ * @param event - one assistant stream event.
79
+ * @returns whether it marks the first output token's arrival.
80
+ */
81
+ export function isTokenDelta(event: AssistantMessageEvent): boolean {
82
+ switch (event.type) {
83
+ case "text_delta":
84
+ case "thinking_delta":
85
+ case "toolcall_delta":
86
+ return event.delta !== "";
87
+ case "toolcall_start":
88
+ return true;
89
+ default:
90
+ return false;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Provider-reported output tokens, guarded so a malformed usage record cannot
96
+ * poison the window with NaN.
97
+ * @param usage - an assistant message's usage record.
98
+ * @returns the output-token count, or undefined when unreported or invalid.
99
+ */
100
+ export function outputTokens(usage: unknown): number | undefined {
101
+ if (typeof usage !== "object" || usage === null) return undefined;
102
+ const value = (usage as Record<string, unknown>).output;
103
+ return isCount(value) ? value : undefined;
104
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@smoose/pi-tps",
3
+ "version": "0.0.1",
4
+ "description": "Decode throughput (tok/s) in the footer of the pi coding agent",
5
+ "type": "module",
6
+ "files": [
7
+ "*.ts",
8
+ "README.md",
9
+ "package.json"
10
+ ],
11
+ "keywords": [
12
+ "pi-package",
13
+ "pi",
14
+ "coding-agent",
15
+ "extension",
16
+ "tps",
17
+ "tokens-per-second",
18
+ "throughput",
19
+ "footer"
20
+ ],
21
+ "author": "smoose",
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/smoosex/pi-tps.git"
26
+ },
27
+ "homepage": "https://github.com/smoosex/pi-tps#readme",
28
+ "bugs": {
29
+ "url": "https://github.com/smoosex/pi-tps/issues"
30
+ },
31
+ "engines": {
32
+ "node": ">=22.19.0"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "registry": "https://registry.npmjs.org/"
37
+ },
38
+ "scripts": {
39
+ "typecheck": "tsc --noEmit",
40
+ "prepublishOnly": "tsc --noEmit"
41
+ },
42
+ "peerDependencies": {
43
+ "@earendil-works/pi-ai": "*",
44
+ "@earendil-works/pi-coding-agent": "*"
45
+ },
46
+ "devDependencies": {
47
+ "@earendil-works/pi-ai": "^0.85.1",
48
+ "@earendil-works/pi-coding-agent": "^0.85.1",
49
+ "typescript": "^7.0.2"
50
+ },
51
+ "pi": {
52
+ "extensions": [
53
+ "./index.ts"
54
+ ]
55
+ }
56
+ }