useful-pi-extensions 1.1.2 → 1.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "useful-pi-extensions",
3
- "version": "1.1.2",
3
+ "version": "1.3.0",
4
4
  "description": "A small collection of pi extensions, installed with one command — a labelled status line with context pressure, cache, cost, effort, TTFT and tokens/sec.",
5
5
  "keywords": [
6
6
  "bun",
@@ -18,7 +18,7 @@
18
18
  * stepStartTime; decodeMs = completedTime - firstTokenTime; tok/s = usage.output / (decodeMs / 1000)
19
19
  */
20
20
 
21
- import { readFile, stat } from 'node:fs/promises'
21
+ import { readFile, writeFile } from 'node:fs/promises'
22
22
  import { homedir } from 'node:os'
23
23
  import { join } from 'node:path'
24
24
 
@@ -37,8 +37,13 @@ import {
37
37
  formatTps,
38
38
  isQuietStatus,
39
39
  pair,
40
+ rateFromPayload,
41
+ cachedRate,
42
+ cacheIsFresh,
43
+ withCachedRate,
40
44
  row,
41
45
  shortenPath,
46
+ ttftDisplay,
42
47
  ttftMs,
43
48
  USD,
44
49
  type Currency,
@@ -47,11 +52,37 @@ import {
47
52
  const LIVE_RENDER_MS = 200
48
53
  const WINDOW_MS = 2000
49
54
  const MIN_SAMPLE_MS = 200
55
+ /** How often the waiting TTFT slot ticks while a request is in flight. */
56
+ const TICK_MS = 250
50
57
  /** Pi's own estimateTokens() heuristic, used only until a real ratio is known. */
51
58
  const FALLBACK_TOKENS_PER_CHAR = 0.25
52
59
 
53
60
  /** The status line's own settings file, alongside pi's other per-tool config. */
54
61
  const CONFIG_PATH = join(homedir(), '.pi', 'agent', 'statusline.json')
62
+ /** Free, keyless, updated once a day — which is exactly the freshness a daily rate wants. */
63
+ const RATES_URL = 'https://open.er-api.com/v6/latest/USD'
64
+ const FETCH_TIMEOUT_MS = 5000
65
+
66
+ function today(): string {
67
+ const now = new Date()
68
+ const month = String(now.getMonth() + 1).padStart(2, '0')
69
+ const day = String(now.getDate()).padStart(2, '0')
70
+ return `${now.getFullYear()}-${month}-${day}`
71
+ }
72
+
73
+ async function fetchRate(code: string): Promise<number | null> {
74
+ const controller = new AbortController()
75
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
76
+ try {
77
+ const response = await fetch(RATES_URL, { signal: controller.signal })
78
+ if (!response.ok) return null
79
+ return rateFromPayload(await response.json(), code)
80
+ } catch {
81
+ return null
82
+ } finally {
83
+ clearTimeout(timer)
84
+ }
85
+ }
55
86
 
56
87
  /**
57
88
  * Reads the display currency.
@@ -60,22 +91,34 @@ const CONFIG_PATH = join(homedir(), '.pi', 'agent', 'statusline.json')
60
91
  * bug. Editing the file therefore takes effect on `/reload`.
61
92
  */
62
93
  async function loadCurrency(notify: (message: string) => void): Promise<Currency> {
63
- try {
64
- await stat(CONFIG_PATH)
65
- } catch {
66
- // No config file is the normal case, and it means USD.
67
- return USD
68
- }
69
- let text: string
94
+ let text: string | null = null
70
95
  try {
71
96
  text = await readFile(CONFIG_PATH, 'utf8')
72
97
  } catch {
73
- notify('statusline.json exists but could not be read, showing USD')
98
+ // No config file is the normal case, and it means USD.
74
99
  return USD
75
100
  }
76
- const { currency, problem } = currencyFromConfig(text)
101
+ const { currency, pending, problem } = currencyFromConfig(text)
77
102
  if (problem !== null) notify(problem)
78
- return currency
103
+ if (pending === null) return currency
104
+
105
+ const cached = cachedRate(text)
106
+ if (cached !== null && cacheIsFresh(cached.fetchedAt, today())) {
107
+ return { symbol: pending.symbol, perUsd: cached.perUsd }
108
+ }
109
+
110
+ const fetched = await fetchRate(pending.code)
111
+ if (fetched !== null) {
112
+ try {
113
+ await writeFile(CONFIG_PATH, withCachedRate(text, fetched, today()))
114
+ } catch {
115
+ // The session still runs on the fetched rate; only tomorrow's warm start is lost.
116
+ }
117
+ return { symbol: pending.symbol, perUsd: fetched }
118
+ }
119
+ if (cached !== null) return { symbol: pending.symbol, perUsd: cached.perUsd }
120
+ notify(`could not fetch a ${pending.code} rate, showing USD`)
121
+ return USD
79
122
  }
80
123
 
81
124
  /** A content block as providers stream it; every field is read defensively. */
@@ -188,6 +231,7 @@ export default function (pi: ExtensionAPI) {
188
231
  let chars = 0
189
232
  let requestAt: number | null = null
190
233
  let firstTokenAt: number | null = null
234
+ let ticker: ReturnType<typeof setInterval> | null = null
191
235
  let windowAt = 0
192
236
  let windowTokens = 0
193
237
  let renderedAt = 0
@@ -205,6 +249,26 @@ export default function (pi: ExtensionAPI) {
205
249
  requestRender?.()
206
250
  }
207
251
 
252
+ /** Stops the live count; a frozen TTFT needs no clock. */
253
+ function stopTicker(): void {
254
+ if (ticker !== null) {
255
+ clearInterval(ticker)
256
+ ticker = null
257
+ }
258
+ }
259
+
260
+ /** Ticks the footer while a request is in flight, until the first token lands or the turn ends. */
261
+ function startTicker(): void {
262
+ if (ticker !== null) return
263
+ ticker = setInterval(() => {
264
+ if (requestAt === null || firstTokenAt !== null) {
265
+ stopTicker()
266
+ return
267
+ }
268
+ requestRender?.()
269
+ }, TICK_MS)
270
+ }
271
+
208
272
  function installFooter(ctx: ExtensionContext): void {
209
273
  ctx.ui.setFooter((tui, theme, footerData: ReadonlyFooterDataProvider) => {
210
274
  requestRender = () => tui.requestRender()
@@ -236,7 +300,12 @@ export default function (pi: ExtensionAPI) {
236
300
  const model = ctx.model?.id ?? 'no model'
237
301
  const row2Parts = [theme.fg('accent', model)]
238
302
  if (ctx.thinkingLevel) row2Parts.push(pair(theme, 'Effort', ctx.thinkingLevel, 'muted'))
239
- if (reading) {
303
+ const waiting = ttftDisplay(requestAt, firstTokenAt, Date.now())
304
+ if (waiting !== null) {
305
+ // The clock is running: this wait has no reading yet, so the previous turn's
306
+ // throughput would only be mistaken for the current one.
307
+ row2Parts.push(pair(theme, 'TTFT', waiting.text, 'muted'))
308
+ } else if (reading) {
240
309
  if (reading.ttftMs !== null)
241
310
  row2Parts.push(pair(theme, 'TTFT', formatLatency(reading.ttftMs), 'muted'))
242
311
  row2Parts.push(
@@ -284,10 +353,28 @@ export default function (pi: ExtensionAPI) {
284
353
  })
285
354
  }
286
355
 
356
+ // A run that ends without a first token (abort, provider error) must not leave a clock counting
357
+ // against a request that is no longer in flight.
358
+ pi.on('turn_end', async () => {
359
+ requestAt = null
360
+ stopTicker()
361
+ })
362
+
363
+ pi.on('agent_end', async () => {
364
+ requestAt = null
365
+ stopTicker()
366
+ })
367
+
368
+ pi.on('agent_settled', async () => {
369
+ requestAt = null
370
+ stopTicker()
371
+ })
372
+
287
373
  pi.on('session_start', async (_event, ctx) => {
288
374
  ratio = seedRatio(ctx)
289
375
  reading = null
290
376
  requestAt = null
377
+ stopTicker()
291
378
  resetStream()
292
379
  currency = await loadCurrency((message) => ctx.ui.notify(message, 'warning'))
293
380
  installFooter(ctx)
@@ -309,6 +396,16 @@ export default function (pi: ExtensionAPI) {
309
396
  requestAt = Date.now()
310
397
  })
311
398
 
399
+ pi.on('turn_start', async () => {
400
+ requestAt = null
401
+ stopTicker()
402
+ })
403
+
404
+ pi.on('before_provider_request', async () => {
405
+ requestAt = Date.now()
406
+ startTicker()
407
+ })
408
+
312
409
  pi.on('message_update', async (event) => {
313
410
  const delta = event.assistantMessageEvent
314
411
  // Narrowing on the discriminant is what keeps the payload typed; membership in
@@ -322,7 +419,10 @@ export default function (pi: ExtensionAPI) {
322
419
  }
323
420
 
324
421
  const now = Date.now()
325
- if (firstTokenAt === null) firstTokenAt = now
422
+ if (firstTokenAt === null) {
423
+ firstTokenAt = now
424
+ stopTicker()
425
+ }
326
426
  chars += delta.delta.length
327
427
 
328
428
  const tokens = chars * (ratio ?? FALLBACK_TOKENS_PER_CHAR)
@@ -357,6 +457,9 @@ export default function (pi: ExtensionAPI) {
357
457
  const tokens = output > 0 ? output : totalChars * (ratio ?? FALLBACK_TOKENS_PER_CHAR)
358
458
  if (measured !== null && decodeMs >= MIN_SAMPLE_MS)
359
459
  publish((tokens / decodeMs) * 1000, output > 0, measured)
460
+ // Null it with the stream: a request that has produced its message is no longer in flight, and
461
+ // a stale anchor would let the live branch count against nothing until the next turn.
462
+ requestAt = null
360
463
  resetStream()
361
464
  })
362
465
  }
@@ -81,6 +81,36 @@ export function ttftMs(requestAt: number | null, firstTokenAt: number | null): n
81
81
  return firstTokenAt > requestAt ? firstTokenAt - requestAt : null
82
82
  }
83
83
 
84
+ /** What the TTFT slot renders, and whether the clock is still running. */
85
+ export interface TtftDisplay {
86
+ text: string
87
+ live: boolean
88
+ }
89
+
90
+ /**
91
+ * The TTFT slot, for all three phases of a request.
92
+ *
93
+ * While the request is in flight the slot counts up (`~2s`, the `~` marking an unfinished wait, the
94
+ * same convention as the tok/s estimate) — a slow first byte is something you watch happen, not a
95
+ * number you are told about afterwards. The moment the first token lands the count freezes into the
96
+ * exact value. With no request in flight there is nothing to show.
97
+ *
98
+ * @param now - The clock, passed in so every branch stays a function of its arguments.
99
+ */
100
+ export function ttftDisplay(
101
+ requestAt: number | null,
102
+ firstTokenAt: number | null,
103
+ now: number,
104
+ ): TtftDisplay | null {
105
+ if (requestAt === null) return null
106
+ if (firstTokenAt === null) {
107
+ if (now < requestAt) return null
108
+ return { text: `~${formatLatency(now - requestAt)}`, live: true }
109
+ }
110
+ const measured = ttftMs(requestAt, firstTokenAt)
111
+ return measured === null ? null : { text: formatLatency(measured), live: false }
112
+ }
113
+
84
114
  /** Home-relative path, or the absolute path when it is outside the home directory. */
85
115
  export function formatCwd(cwd: string): string {
86
116
  const home = homedir()
@@ -139,48 +169,129 @@ function isRecord(value: unknown): value is Record<string, unknown> {
139
169
  * @param raw - The `currency` value from a parsed config file.
140
170
  * @returns The currency, or null when the block could not be used.
141
171
  */
172
+ /** The identity a currency block asks for: the API code and the symbol to print. */
173
+ interface CurrencyIdentity {
174
+ code: string
175
+ symbol: string
176
+ }
177
+
178
+ function currencyIdentity(raw: Record<string, unknown>): CurrencyIdentity | null {
179
+ const code = typeof raw.code === 'string' ? raw.code.trim().toUpperCase() : ''
180
+ const explicit = typeof raw.symbol === 'string' ? raw.symbol : ''
181
+ const symbol = explicit || CURRENCY_SYMBOLS[code] || code
182
+ return symbol === '' ? null : { code, symbol }
183
+ }
184
+
142
185
  export function parseCurrency(raw: unknown): Currency | null {
143
186
  if (!isRecord(raw)) return null
144
187
  const rate = raw.perUsd
145
188
  if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) return null
146
- const code = typeof raw.code === 'string' ? raw.code.trim().toUpperCase() : ''
147
- const explicit = typeof raw.symbol === 'string' ? raw.symbol : ''
148
- const symbol = explicit || CURRENCY_SYMBOLS[code] || code
149
- return symbol === '' ? null : { symbol, perUsd: rate }
189
+ const identity = currencyIdentity(raw)
190
+ return identity === null ? null : { symbol: identity.symbol, perUsd: rate }
150
191
  }
151
192
 
152
- /** A currency, plus the message to show when the config named one that could not be used. */
193
+ /** A currency, plus what to do when the config named one without a rate of its own. */
153
194
  export interface CurrencyConfig {
154
195
  currency: Currency
196
+ /** Set when the block names a code but no rate: the rate comes from a daily fetch. */
197
+ pending: { code: string; symbol: string } | null
155
198
  problem: string | null
156
199
  }
157
200
 
158
201
  /**
159
202
  * The display currency a config file asks for.
160
203
  *
204
+ * A block with `perUsd` is a rate the user pinned, and is used as it is. A block with only a `code`
205
+ * asks for the market rate, which the caller fetches once a day — this returns what to fetch and
206
+ * the symbol to spend it with, and the caller decides what a failed fetch falls back to.
207
+ *
161
208
  * @param text - The contents of the config file, or null when it does not exist. Absent is the
162
209
  * normal case and is not a problem; a file that is there but unusable is, because the footer
163
210
  * would otherwise keep showing dollars with nothing to explain it.
164
211
  */
165
212
  export function currencyFromConfig(text: string | null): CurrencyConfig {
166
- if (text === null) return { currency: USD, problem: null }
213
+ if (text === null) return { currency: USD, pending: null, problem: null }
167
214
  let config: unknown
168
215
  try {
169
216
  config = JSON.parse(text)
170
217
  } catch {
171
- return { currency: USD, problem: 'statusline.json is not valid JSON, showing USD' }
218
+ return {
219
+ currency: USD,
220
+ pending: null,
221
+ problem: 'statusline.json is not valid JSON, showing USD',
222
+ }
172
223
  }
173
224
  if (!isRecord(config) || !Object.hasOwn(config, 'currency')) {
174
- return { currency: USD, problem: null }
225
+ return { currency: USD, pending: null, problem: null }
175
226
  }
176
- const currency = parseCurrency(config.currency)
177
- if (currency === null) {
227
+ if (!isRecord(config.currency)) {
178
228
  return {
179
229
  currency: USD,
180
- problem: 'statusline.json currency needs a positive perUsd, showing USD',
230
+ pending: null,
231
+ problem: 'statusline.json currency needs a code with a rate, or perUsd, showing USD',
232
+ }
233
+ }
234
+ const identity = currencyIdentity(config.currency)
235
+ const rate = config.currency.perUsd
236
+ if (typeof rate === 'number' && Number.isFinite(rate) && rate > 0) {
237
+ return {
238
+ currency: { symbol: identity?.symbol ?? '$', perUsd: rate },
239
+ pending: null,
240
+ problem: null,
181
241
  }
182
242
  }
183
- return { currency, problem: null }
243
+ if (identity !== null && !Object.hasOwn(config.currency, 'perUsd')) {
244
+ return { currency: USD, pending: identity, problem: null }
245
+ }
246
+ return {
247
+ currency: USD,
248
+ pending: null,
249
+ problem: 'statusline.json currency needs a code with a rate, or perUsd, showing USD',
250
+ }
251
+ }
252
+
253
+ /** One rate out of an open.er-api.com payload (`{"rates": {"CNY": 7.12}}`), read defensively. */
254
+ export function rateFromPayload(payload: unknown, code: string): number | null {
255
+ if (!isRecord(payload) || !isRecord(payload.rates)) return null
256
+ const rate = payload.rates[code]
257
+ return typeof rate === 'number' && Number.isFinite(rate) && rate > 0 ? rate : null
258
+ }
259
+
260
+ /** The rate a config file cached from an earlier fetch, and the day it was fetched. */
261
+ export function cachedRate(text: string): { perUsd: number; fetchedAt: string } | null {
262
+ let config: unknown
263
+ try {
264
+ config = JSON.parse(text)
265
+ } catch {
266
+ return null
267
+ }
268
+ if (!isRecord(config)) return null
269
+ const perUsd = config.fetchedPerUsd
270
+ const fetchedAt = config.fetchedAt
271
+ if (typeof perUsd !== 'number' || !Number.isFinite(perUsd) || perUsd <= 0) return null
272
+ return typeof fetchedAt === 'string' ? { perUsd, fetchedAt } : null
273
+ }
274
+
275
+ /** Whether a cached rate was fetched today, both dates as `YYYY-MM-DD`. */
276
+ export function cacheIsFresh(fetchedAt: string, today: string): boolean {
277
+ return fetchedAt.slice(0, 10) === today
278
+ }
279
+
280
+ /**
281
+ * The config file's text with a fetched rate recorded in it, so the next session starts warm.
282
+ *
283
+ * Unknown keys are kept, and a file that does not parse is returned untouched: the extension has no
284
+ * business replacing a config it could not read with one it wrote.
285
+ */
286
+ export function withCachedRate(text: string, perUsd: number, fetchedAt: string): string {
287
+ let config: unknown
288
+ try {
289
+ config = JSON.parse(text)
290
+ } catch {
291
+ return text
292
+ }
293
+ if (!isRecord(config)) return text
294
+ return `${JSON.stringify({ ...config, fetchedPerUsd: perUsd, fetchedAt }, null, 2)}\n`
184
295
  }
185
296
 
186
297
  /** Three decimals, with one trailing zero trimmed so `$0.380` renders as `$0.38`. */