@wierdbytes/pi-facelift 0.6.5 → 0.6.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.6
4
+
5
+ - **Fix:** prompt-cache warm-ups no longer inflate `worked` time / deflate `tps` or start a phantom working timer while idle.
6
+
3
7
  ## 0.6.5
4
8
 
5
9
  - **Fix:** pi crashing with "extension ctx is stale" when the session was replaced or reloaded while the working timer was ticking.
package/README.md CHANGED
@@ -49,7 +49,8 @@ tool output.
49
49
  `⏱ worked 45.2s (total: 1m15s) · tps: 756`
50
50
 
51
51
  - `worked` — model-only time (request dispatch → response end, so
52
- time-to-first-token counts; tool execution is excluded).
52
+ time-to-first-token counts; tool execution and pi's prompt-cache
53
+ warm-up requests are excluded).
53
54
  - `total` — wall-clock time from your prompt to the final response,
54
55
  including retries, tool calls, and all other overhead.
55
56
  - `tps` — output tokens per second over the `worked` time. The live
package/index.ts CHANGED
@@ -1211,11 +1211,22 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1211
1211
  workingTracker.ensureRun();
1212
1212
  });
1213
1213
 
1214
+ // pi emits `turn_start` (awaited) right before every agent request, so it
1215
+ // arms the tracker. Cache-warmer replays also fire `before_provider_request`
1216
+ // but arrive outside this window (during tools or after settle) and never
1217
+ // get a `message_end` — unguarded they would hold a segment open forever.
1218
+ pi.on("turn_start", () => {
1219
+ if (!faceliftConfig.showWorkingTime) return;
1220
+ workingTracker.armRequest();
1221
+ });
1222
+
1214
1223
  pi.on("before_provider_request", (_event, ctx) => {
1215
1224
  if (!faceliftConfig.showWorkingTime) return;
1216
1225
  // Start the segment when the request is dispatched (not on the first
1217
- // token), so time-to-first-token counts as working time too.
1218
- workingTracker.beginSegment();
1226
+ // token), so time-to-first-token counts as working time too. A `false`
1227
+ // means a cache warm-up (not armed) or a repeat payload hook for an
1228
+ // already-open request — leave the ticker alone.
1229
+ if (!workingTracker.beginSegment()) return;
1219
1230
  stopWorkingTick();
1220
1231
  renderWorkingMessage(ctx);
1221
1232
  workingTick = setInterval(() => renderWorkingMessage(ctx), 1000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wierdbytes/pi-facelift",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "description": "Cosmetic facelift for built-in pi tool output — open-right rounded frames, syntax-highlighted reads, status-aware bash summaries, find/grep.",
5
5
  "type": "module",
6
6
  "files": [
@@ -4,9 +4,13 @@
4
4
  * The tracker measures two metrics: `worked` (model streaming only,
5
5
  * tools excluded) and `totalMs` (wall-clock from user prompt to settle,
6
6
  * retries/tools/overhead included). The wiring drives it from pi
7
- * lifecycle events: before_agent_start / agent_start /
7
+ * lifecycle events: before_agent_start / agent_start / turn_start /
8
8
  * before_provider_request / message_end / agent_settled. The durable
9
9
  * history line is produced via pi.appendEntry + registerEntryRenderer.
10
+ *
11
+ * `before_provider_request` also fires for pi's prompt-cache warm-ups,
12
+ * which never produce a `message_end`; only requests armed by `turn_start`
13
+ * may open a segment.
10
14
  */
11
15
 
12
16
  import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -27,6 +31,7 @@ describe("WorkingTimeTracker", () => {
27
31
  const t = new WorkingTimeTracker(() => now);
28
32
  t.beginRun();
29
33
  now = 2000;
34
+ t.armRequest();
30
35
  t.beginSegment();
31
36
  now = 4500;
32
37
  t.endSegment();
@@ -40,11 +45,13 @@ describe("WorkingTimeTracker", () => {
40
45
  let now = 0;
41
46
  const t = new WorkingTimeTracker(() => now);
42
47
  t.beginRun();
48
+ t.armRequest();
43
49
  t.beginSegment();
44
50
  now = 1000;
45
51
  t.endSegment();
46
52
  // tool + overhead from 1000..9000 counts toward total, not worked
47
53
  now = 9000;
54
+ t.armRequest();
48
55
  t.beginSegment();
49
56
  now = 10000;
50
57
  t.endSegment();
@@ -57,6 +64,7 @@ describe("WorkingTimeTracker", () => {
57
64
  let now = 0;
58
65
  const t = new WorkingTimeTracker(() => now);
59
66
  t.beginRun();
67
+ t.armRequest();
60
68
  t.beginSegment();
61
69
  now = 500;
62
70
  t.endSegment();
@@ -71,6 +79,7 @@ describe("WorkingTimeTracker", () => {
71
79
  let now = 0;
72
80
  const t = new WorkingTimeTracker(() => now);
73
81
  t.beginRun();
82
+ t.armRequest();
74
83
  t.beginSegment();
75
84
  t.addDelta(4000); // ~1000 estimated tokens at 4 chars/token
76
85
  now = 2000; // 2s of worked time
@@ -81,6 +90,7 @@ describe("WorkingTimeTracker", () => {
81
90
  it("liveTps is 0 before any time elapses", () => {
82
91
  const t = new WorkingTimeTracker(() => 0);
83
92
  t.beginRun();
93
+ t.armRequest();
84
94
  t.beginSegment();
85
95
  t.addDelta(400);
86
96
  expect(t.liveTps()).toBe(0);
@@ -90,6 +100,7 @@ describe("WorkingTimeTracker", () => {
90
100
  let now = 0;
91
101
  const t = new WorkingTimeTracker(() => now);
92
102
  t.beginRun();
103
+ t.armRequest();
93
104
  t.beginSegment();
94
105
  now = 2000;
95
106
  t.recordExactTokens(1500);
@@ -104,12 +115,14 @@ describe("WorkingTimeTracker", () => {
104
115
  const t = new WorkingTimeTracker(() => now);
105
116
  t.beginRun();
106
117
  // first model segment: 0..1000
118
+ t.armRequest();
107
119
  t.beginSegment();
108
120
  now = 1000;
109
121
  t.endSegment();
110
122
  // "tool" runs from 1000..9000 — must NOT count
111
123
  now = 9000;
112
124
  // second model segment: 9000..10000
125
+ t.armRequest();
113
126
  t.beginSegment();
114
127
  now = 10000;
115
128
  t.endSegment();
@@ -120,6 +133,7 @@ describe("WorkingTimeTracker", () => {
120
133
  let now = 0;
121
134
  const t = new WorkingTimeTracker(() => now);
122
135
  t.beginRun();
136
+ t.armRequest();
123
137
  t.beginSegment();
124
138
  now = 3000;
125
139
  expect(t.elapsedMs()).toBe(3000);
@@ -132,10 +146,63 @@ describe("WorkingTimeTracker", () => {
132
146
  expect(t.settle()).toBeUndefined();
133
147
  });
134
148
 
149
+ it("ignores an unarmed request (cache warm-up while idle)", () => {
150
+ let now = 0;
151
+ const t = new WorkingTimeTracker(() => now);
152
+ expect(t.beginSegment()).toBe(false);
153
+ now = 5000;
154
+ expect(t.isActive()).toBe(false);
155
+ expect(t.elapsedMs()).toBe(0);
156
+ });
157
+
158
+ it("ignores a cache warm-up during tool execution", () => {
159
+ let now = 0;
160
+ const t = new WorkingTimeTracker(() => now);
161
+ t.beginRun();
162
+ t.armRequest();
163
+ expect(t.beginSegment()).toBe(true);
164
+ now = 1000;
165
+ t.endSegment();
166
+ // tool runs 1000..9000; the warmer replays the request at 4000
167
+ now = 4000;
168
+ expect(t.beginSegment()).toBe(false);
169
+ now = 9000;
170
+ t.armRequest();
171
+ expect(t.beginSegment()).toBe(true);
172
+ now = 10000;
173
+ t.endSegment();
174
+ expect(t.settle()?.ms).toBe(2000);
175
+ });
176
+
177
+ it("keeps the first start when the request hook fires again mid-segment", () => {
178
+ let now = 0;
179
+ const t = new WorkingTimeTracker(() => now);
180
+ t.beginRun();
181
+ t.armRequest();
182
+ expect(t.beginSegment()).toBe(true);
183
+ now = 1000;
184
+ expect(t.beginSegment()).toBe(false); // e.g. a provider re-sending the payload
185
+ now = 3000;
186
+ t.endSegment();
187
+ expect(t.settle()?.ms).toBe(3000);
188
+ });
189
+
190
+ it("disarms on settle and on beginRun", () => {
191
+ const t = new WorkingTimeTracker(() => 0);
192
+ t.beginRun();
193
+ t.armRequest();
194
+ t.settle();
195
+ expect(t.beginSegment()).toBe(false);
196
+ t.armRequest();
197
+ t.beginRun();
198
+ expect(t.beginSegment()).toBe(false);
199
+ });
200
+
135
201
  it("resets state after settle", () => {
136
202
  let now = 0;
137
203
  const t = new WorkingTimeTracker(() => now);
138
204
  t.beginRun();
205
+ t.armRequest();
139
206
  t.beginSegment();
140
207
  now = 1000;
141
208
  t.endSegment();
@@ -264,11 +331,13 @@ describe("working-time wiring", () => {
264
331
  fire("before_agent_start", {}); // anchors the total clock
265
332
  vi.advanceTimersByTime(500); // pre-loop overhead
266
333
  fire("agent_start", {});
334
+ fire("turn_start", {});
267
335
  fire("before_provider_request", {});
268
336
  vi.advanceTimersByTime(5000);
269
337
  fire("message_end", asst);
270
338
  // tool gap — counts toward total, NOT worked
271
339
  vi.advanceTimersByTime(3000);
340
+ fire("turn_start", {});
272
341
  fire("before_provider_request", {});
273
342
  vi.advanceTimersByTime(2000);
274
343
  fire("message_end", asst);
@@ -287,6 +356,7 @@ describe("working-time wiring", () => {
287
356
  const { fire, entries, ctx } = loadExtension();
288
357
  fire("before_agent_start", {});
289
358
  fire("agent_start", {});
359
+ fire("turn_start", {});
290
360
  fire("before_provider_request", {});
291
361
  // Stream ~4000 chars => ~1000 estimated tokens over 2s of worked time.
292
362
  fire("message_update", {
@@ -311,6 +381,7 @@ describe("working-time wiring", () => {
311
381
  const { fire, entries } = loadExtension();
312
382
  fire("before_agent_start", {});
313
383
  fire("agent_start", {});
384
+ fire("turn_start", {});
314
385
  fire("before_provider_request", {});
315
386
  vi.advanceTimersByTime(1000);
316
387
  fire("message_end", user); // stray non-assistant end — ignored
@@ -327,6 +398,7 @@ describe("working-time wiring", () => {
327
398
  const { fire, ctx } = loadExtension();
328
399
  fire("before_agent_start", {});
329
400
  fire("agent_start", {});
401
+ fire("turn_start", {});
330
402
  fire("before_provider_request", {});
331
403
  vi.advanceTimersByTime(2000);
332
404
  const calls = ctx.ui.setWorkingMessage.mock.calls.map((c) => c[0]);
@@ -340,6 +412,64 @@ describe("working-time wiring", () => {
340
412
  expect(last?.[0]).toBeUndefined();
341
413
  });
342
414
 
415
+ it("ignores a cache warm-up during tool execution (no segment, no ticker)", () => {
416
+ vi.useFakeTimers();
417
+ const { fire, entries, ctx } = loadExtension();
418
+ fire("before_agent_start", {});
419
+ fire("agent_start", {});
420
+ fire("turn_start", {});
421
+ fire("before_provider_request", {});
422
+ vi.advanceTimersByTime(1000);
423
+ fire("message_end", asst);
424
+ // tool executes; the cache warmer replays the request mid-tool
425
+ vi.advanceTimersByTime(2000);
426
+ const callsBefore = ctx.ui.setWorkingMessage.mock.calls.length;
427
+ fire("before_provider_request", {});
428
+ vi.advanceTimersByTime(5000);
429
+ expect(ctx.ui.setWorkingMessage.mock.calls.length).toBe(callsBefore);
430
+ fire("turn_start", {});
431
+ fire("before_provider_request", {});
432
+ vi.advanceTimersByTime(1000);
433
+ fire("message_end", asst);
434
+ fire("agent_settled", {});
435
+ vi.useRealTimers();
436
+ expect(entries).toHaveLength(1);
437
+ const data = entries[0].data as WorkingTimeEntry;
438
+ expect(data.ms).toBe(2000); // the 7s tool gap stays excluded
439
+ expect(data.totalMs).toBe(9000);
440
+ });
441
+
442
+ it("ignores a cache warm-up after settle (idle warming): no phantom run or ticker", () => {
443
+ vi.useFakeTimers();
444
+ const { fire, entries, ctx } = loadExtension();
445
+ fire("before_agent_start", {});
446
+ fire("agent_start", {});
447
+ fire("turn_start", {});
448
+ fire("before_provider_request", {});
449
+ vi.advanceTimersByTime(1000);
450
+ fire("message_end", asst);
451
+ fire("agent_settled", {});
452
+ ctx.ui.setWorkingMessage.mockClear();
453
+ // idle warm-up minutes later
454
+ vi.advanceTimersByTime(270_000);
455
+ fire("before_provider_request", {});
456
+ vi.advanceTimersByTime(5000);
457
+ expect(ctx.ui.setWorkingMessage).not.toHaveBeenCalled();
458
+ // the next real run is measured from its own prompt
459
+ fire("before_agent_start", {});
460
+ fire("agent_start", {});
461
+ fire("turn_start", {});
462
+ fire("before_provider_request", {});
463
+ vi.advanceTimersByTime(1500);
464
+ fire("message_end", asst);
465
+ fire("agent_settled", {});
466
+ vi.useRealTimers();
467
+ expect(entries).toHaveLength(2);
468
+ const data = entries[1].data as WorkingTimeEntry;
469
+ expect(data.ms).toBe(1500);
470
+ expect(data.totalMs).toBe(1500);
471
+ });
472
+
343
473
  it("registers an entry renderer that produces a dim worked+total line", () => {
344
474
  const { entryRenderers } = loadExtension();
345
475
  const renderer = entryRenderers.get(WORKING_TIME_ENTRY);
package/working-time.ts CHANGED
@@ -61,6 +61,7 @@ export interface WorkingTimeEntry {
61
61
  * Lifecycle (driven by pi events):
62
62
  * before_agent_start → beginRun() (total clock starts here)
63
63
  * agent_start → ensureRun() (fallback if before_agent_start skipped)
64
+ * turn_start → armRequest() (an agent request is coming)
64
65
  * before_provider_request → beginSegment() + start the live ticker
65
66
  * message_update (asst) → addDelta() (feed the live tps estimate)
66
67
  * message_end (asst) → recordExactTokens() + endSegment() + stop ticker
@@ -68,6 +69,15 @@ export interface WorkingTimeEntry {
68
69
  *
69
70
  * Retries / auto-compaction fire their own agent_start but pi only emits
70
71
  * `agent_settled` when it truly stops, so a run is measured end-to-end.
72
+ *
73
+ * `before_provider_request` is not exclusive to agent turns: pi's prompt
74
+ * cache warmer replays the last request (`maxTokens: 1`) with the same
75
+ * options, so the hook fires for warm-ups too — mid-run while tools execute,
76
+ * and after the run settles in `idle` warming mode. Those requests never
77
+ * produce a `message_end`, so an unguarded segment would stay open and
78
+ * swallow tool time (or start a phantom run while idle). Segments therefore
79
+ * open only while armed: from `turn_start` (pi emits one before every agent
80
+ * request) until the assistant `message_end`.
71
81
  */
72
82
  export class WorkingTimeTracker {
73
83
  private readonly now: () => number;
@@ -76,6 +86,7 @@ export class WorkingTimeTracker {
76
86
  private runStart: number | undefined;
77
87
  private runEstChars = 0;
78
88
  private runExactTokens = 0;
89
+ private requestArmed = false;
79
90
 
80
91
  constructor(now: () => number = () => Date.now()) {
81
92
  this.now = now;
@@ -88,6 +99,7 @@ export class WorkingTimeTracker {
88
99
  this.runStart = this.now();
89
100
  this.runEstChars = 0;
90
101
  this.runExactTokens = 0;
102
+ this.requestArmed = false;
91
103
  }
92
104
 
93
105
  /** Start a run only if one isn't already in progress (idempotent). */
@@ -95,14 +107,29 @@ export class WorkingTimeTracker {
95
107
  if (this.runStart === undefined) this.beginRun();
96
108
  }
97
109
 
98
- /** Begin timing an assistant streaming segment. */
99
- beginSegment(): void {
110
+ /**
111
+ * Mark that the agent is about to issue a real request (pi `turn_start`).
112
+ * Only an armed tracker lets `beginSegment` open a segment.
113
+ */
114
+ armRequest(): void {
100
115
  this.ensureRun();
101
- if (this.segmentStart === undefined) this.segmentStart = this.now();
116
+ this.requestArmed = true;
117
+ }
118
+
119
+ /**
120
+ * Begin timing an assistant streaming segment. Ignored unless armed (see
121
+ * `armRequest`) or when a segment is already open. Returns `true` only
122
+ * when a new segment was opened.
123
+ */
124
+ beginSegment(): boolean {
125
+ if (!this.requestArmed || this.segmentStart !== undefined) return false;
126
+ this.segmentStart = this.now();
127
+ return true;
102
128
  }
103
129
 
104
- /** Close the current segment, folding its duration into the total. */
130
+ /** Close the current segment, folding its duration into the total, and disarm. */
105
131
  endSegment(): void {
132
+ this.requestArmed = false;
106
133
  if (this.segmentStart !== undefined) {
107
134
  this.accumulatorMs += Math.max(0, this.now() - this.segmentStart);
108
135
  this.segmentStart = undefined;