useful-pi-extensions 1.3.0 → 1.5.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.3.0",
3
+ "version": "1.5.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",
@@ -53,29 +53,34 @@ Configuration lives at the top of [`render.ts`](render.ts):
53
53
  ## Currency
54
54
 
55
55
  pi prices every model in USD and its `cost` field carries no unit at all, so the footer cannot know
56
- what you were actually billed. What a session cost in RMB is set by whoever sold you the credit, not
57
- by a market feed, which is why the rate is configured rather than fetched.
58
-
59
- Create `~/.pi/agent/statusline.json`:
56
+ what you were actually billed. The currency and the rate live in `~/.pi/agent/statusline.json`:
60
57
 
61
58
  ```json
62
59
  {
63
60
  "currency": {
64
- "code": "CNY",
65
- "perUsd": 7.12
61
+ "code": "CNY"
66
62
  }
67
63
  }
68
64
  ```
69
65
 
70
- - `code` picks the symbol (`CNY` and `RMB` give `¥`; the table also knows `USD`, `EUR`, `GBP`, `JPY`,
71
- `HKD`, `TWD`, `SGD`, `KRW` and `INR`). A code the table does not know is printed as it is.
72
- - `symbol` overrides the table, for a currency it does not list or a different separator.
73
- - `perUsd` is how many units of that currency one dollar buys the rate you were actually charged.
74
- It has to be a positive number; anything else is refused and the footer stays in USD.
66
+ Two ways to set the rate:
67
+
68
+ - **Follow the market.** A `code` with no rate fetches the day's table from open.er-api.com on the
69
+ first session of each day and caches the whole table in the file (`rates` + `fetchedAt`), so the
70
+ next session starts warm and switching codes is instant and offline.
71
+ - **Pin it.** Add `"perUsd": 7.12` and the footer always uses that rate, never touching the network
72
+ — the right choice when your platform bills in RMB directly, because a domestic price list is not
73
+ the dollar list times a market rate.
74
+
75
+ `code` picks the symbol: `¥` for `CNY` and `RMB`, `JP¥` for `JPY` so the two never trade places, and
76
+ the table also knows `USD`, `EUR`, `GBP`, `HKD`, `TWD`, `SGD`, `KRW`, `INR`, `AUD`, `CAD`, `NZD` and
77
+ `CHF`; an unknown code is printed as it is. `symbol` overrides all of that.
75
78
 
76
- With the file above, the same session reads `Cost ¥1.63` instead of `Cost $0.229`. The file is read
77
- once per session, so edit it and `/reload`. A file that is there but unusable says so in a
78
- notification, rather than silently showing dollars with nothing to explain why.
79
+ Switching is editing `code`, or `make currency CODE=JPY` from a checkout. The file is read once per
80
+ session, so finish with `/reload`. A file that is there but unusable says so in a notification,
81
+ rather than silently showing dollars with nothing to explain why.
82
+
83
+ ## Metrics
79
84
 
80
85
  ## Metrics
81
86
 
@@ -51,27 +51,31 @@ LSP Active: typescript
51
51
  ## 币种
52
52
 
53
53
  pi 里所有模型价格都是美元,而且它的 `cost` 字段**不带任何单位**,所以 footer 无从知道你实际是按什么币种付的。
54
- 这次会话折合多少人民币,取决于卖你额度那家给的汇率,而不是市场汇率——所以汇率是**配置**出来的,不是联网抓的。
55
-
56
- 创建 `~/.pi/agent/statusline.json`:
54
+ 币种与汇率放在 `~/.pi/agent/statusline.json`:
57
55
 
58
56
  ```json
59
57
  {
60
58
  "currency": {
61
- "code": "CNY",
62
- "perUsd": 7.12
59
+ "code": "CNY"
63
60
  }
64
61
  }
65
62
  ```
66
63
 
67
- - `code` 决定符号(`CNY` 与 `RMB` 得到 `¥`;符号表还认 `USD`、`EUR`、`GBP`、`JPY`、`HKD`、`TWD`、
68
- `SGD`、`KRW`、`INR`)。表里没有的代码就原样打印。
69
- - `symbol` 可覆盖符号表,用于表里没列的币种,或想换分隔方式。
70
- - `perUsd` 是一美元等于多少该币种,也就是**你实际被收的汇率**。必须是正数;
71
- 否则整块被拒绝,footer 继续显示美元。
64
+ 汇率有两种设法:
65
+
66
+ - **跟随市场。** 只写 `code` 不写汇率时,每天第一个会话会从 open.er-api.com 拉取当日汇率表,
67
+ 并把整张表缓存进文件(`rates` + `fetchedAt`),下个会话热启动;切换币种即时生效、离线也能切。
68
+ - **钉死。** 加上 `"perUsd": 7.12`,footer 永远用这个汇率、绝不联网——如果你的平台直接按人民币计费,
69
+ 这是正确选择:国内价目表不是美元价目表乘以市场汇率。
70
+
71
+ `code` 决定符号:`CNY` 和 `RMB` 得到 `¥`,`JPY` 得到 `JP¥`(两者永不混用),符号表还认 `USD`、
72
+ `EUR`、`GBP`、`HKD`、`TWD`、`SGD`、`KRW`、`INR`、`AUD`、`CAD`、`NZD`、`CHF`;表里没有的代码原样打印。
73
+ `symbol` 可覆盖这一切。
72
74
 
73
- 配置成上面这样,同一个会话就从 `Cost $0.229` 变成 `Cost ¥1.63`。该文件每个会话只读一次,
74
- 改完需 `/reload`。文件存在但不可用时会给出通知,而不是静默继续显示美元。
75
+ 切换就是改 `code`,或者在检出里执行 `make currency CODE=JPY`。该文件每个会话只读一次,改完需 `/reload`。
76
+ 文件存在但不可用时会给出通知,而不是静默继续显示美元。
77
+
78
+ ## 指标口径
75
79
 
76
80
  ## 指标口径
77
81
 
@@ -30,6 +30,7 @@ import type {
30
30
  import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'
31
31
 
32
32
  import {
33
+ avgTokPerSec,
33
34
  contextRow,
34
35
  currencyFromConfig,
35
36
  formatCwd,
@@ -37,10 +38,10 @@ import {
37
38
  formatTps,
38
39
  isQuietStatus,
39
40
  pair,
40
- rateFromPayload,
41
- cachedRate,
41
+ cachedRates,
42
+ ratesFromPayload,
42
43
  cacheIsFresh,
43
- withCachedRate,
44
+ withCachedRates,
44
45
  row,
45
46
  shortenPath,
46
47
  ttftDisplay,
@@ -59,7 +60,7 @@ const FALLBACK_TOKENS_PER_CHAR = 0.25
59
60
 
60
61
  /** The status line's own settings file, alongside pi's other per-tool config. */
61
62
  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
+ /** Free, keyless, and one request returns every currency so the cache serves instant switching. */
63
64
  const RATES_URL = 'https://open.er-api.com/v6/latest/USD'
64
65
  const FETCH_TIMEOUT_MS = 5000
65
66
 
@@ -70,13 +71,14 @@ function today(): string {
70
71
  return `${now.getFullYear()}-${month}-${day}`
71
72
  }
72
73
 
73
- async function fetchRate(code: string): Promise<number | null> {
74
+ async function fetchRates(): Promise<Record<string, number> | null> {
74
75
  const controller = new AbortController()
75
76
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
76
77
  try {
77
78
  const response = await fetch(RATES_URL, { signal: controller.signal })
78
79
  if (!response.ok) return null
79
- return rateFromPayload(await response.json(), code)
80
+ const rates = ratesFromPayload(await response.json())
81
+ return Object.keys(rates).length === 0 ? null : rates
80
82
  } catch {
81
83
  return null
82
84
  } finally {
@@ -87,6 +89,11 @@ async function fetchRate(code: string): Promise<number | null> {
87
89
  /**
88
90
  * Reads the display currency.
89
91
  *
92
+ * `perUsd` is a pinned rate and wins untouched; a code without one resolves through the cached rate
93
+ * table — today's table answers instantly, a stale one answers while a fresh one is fetched, and
94
+ * only a first-ever failure speaks up. The whole table is cached because one request returns every
95
+ * currency, which is also what makes switching codes instant and offline.
96
+ *
90
97
  * Called once per session on purpose: a footer that stat'ed a file on every frame would be its own
91
98
  * bug. Editing the file therefore takes effect on `/reload`.
92
99
  */
@@ -102,21 +109,22 @@ async function loadCurrency(notify: (message: string) => void): Promise<Currency
102
109
  if (problem !== null) notify(problem)
103
110
  if (pending === null) return currency
104
111
 
105
- const cached = cachedRate(text)
106
- if (cached !== null && cacheIsFresh(cached.fetchedAt, today())) {
107
- return { symbol: pending.symbol, perUsd: cached.perUsd }
112
+ const cached = cachedRates(text)
113
+ const known = cached?.rates[pending.code]
114
+ if (cached !== null && known !== undefined && cacheIsFresh(cached.fetchedAt, today())) {
115
+ return { symbol: pending.symbol, perUsd: known }
108
116
  }
109
117
 
110
- const fetched = await fetchRate(pending.code)
118
+ const fetched = await fetchRates()
111
119
  if (fetched !== null) {
112
120
  try {
113
- await writeFile(CONFIG_PATH, withCachedRate(text, fetched, today()))
121
+ await writeFile(CONFIG_PATH, withCachedRates(text, fetched, today()))
114
122
  } catch {
115
123
  // The session still runs on the fetched rate; only tomorrow's warm start is lost.
116
124
  }
117
- return { symbol: pending.symbol, perUsd: fetched }
125
+ return { symbol: pending.symbol, perUsd: fetched[pending.code] ?? USD.perUsd }
118
126
  }
119
- if (cached !== null) return { symbol: pending.symbol, perUsd: cached.perUsd }
127
+ if (known !== undefined) return { symbol: pending.symbol, perUsd: known }
120
128
  notify(`could not fetch a ${pending.code} rate, showing USD`)
121
129
  return USD
122
130
  }
@@ -231,6 +239,7 @@ export default function (pi: ExtensionAPI) {
231
239
  let chars = 0
232
240
  let requestAt: number | null = null
233
241
  let firstTokenAt: number | null = null
242
+ let totalDecodeMs = 0
234
243
  let ticker: ReturnType<typeof setInterval> | null = null
235
244
  let windowAt = 0
236
245
  let windowTokens = 0
@@ -281,6 +290,7 @@ export default function (pi: ExtensionAPI) {
281
290
  render(width: number): string[] {
282
291
  const usage = ctx.getContextUsage()
283
292
  const totals = collectTotals(ctx)
293
+ const avg = avgTokPerSec(totals.output, totalDecodeMs)
284
294
  const row1 = contextRow(
285
295
  theme,
286
296
  width,
@@ -309,12 +319,15 @@ export default function (pi: ExtensionAPI) {
309
319
  if (reading.ttftMs !== null)
310
320
  row2Parts.push(pair(theme, 'TTFT', formatLatency(reading.ttftMs), 'muted'))
311
321
  row2Parts.push(
312
- theme.fg(
313
- reading.exact ? 'success' : 'dim',
322
+ pair(
323
+ theme,
324
+ 'Last',
314
325
  `${reading.exact ? '' : '~'}${formatTps(reading.rate)} tok/s`,
326
+ reading.exact ? 'success' : 'dim',
315
327
  ),
316
328
  )
317
329
  }
330
+ if (avg !== null) row2Parts.push(pair(theme, 'Avg', `${formatTps(avg)} tok/s`, 'muted'))
318
331
  const row2Right = row2Parts.join(theme.fg('dim', ' · '))
319
332
 
320
333
  const branch = footerData.getGitBranch()
@@ -374,6 +387,7 @@ export default function (pi: ExtensionAPI) {
374
387
  ratio = seedRatio(ctx)
375
388
  reading = null
376
389
  requestAt = null
390
+ totalDecodeMs = 0
377
391
  stopTicker()
378
392
  resetStream()
379
393
  currency = await loadCurrency((message) => ctx.ui.notify(message, 'warning'))
@@ -455,8 +469,11 @@ export default function (pi: ExtensionAPI) {
455
469
  const decodeMs = firstTokenAt !== null ? Date.now() - firstTokenAt : 0
456
470
  const measured = ttftMs(requestAt, firstTokenAt)
457
471
  const tokens = output > 0 ? output : totalChars * (ratio ?? FALLBACK_TOKENS_PER_CHAR)
458
- if (measured !== null && decodeMs >= MIN_SAMPLE_MS)
472
+ if (measured !== null && decodeMs >= MIN_SAMPLE_MS) {
473
+ // Session-average numerator lives in collectTotals; this is its denominator.
474
+ totalDecodeMs += decodeMs
459
475
  publish((tokens / decodeMs) * 1000, output > 0, measured)
476
+ }
460
477
  // Null it with the stream: a request that has produced its message is no longer in flight, and
461
478
  // a stale anchor would let the live branch count against nothing until the next turn.
462
479
  requestAt = null
@@ -143,13 +143,18 @@ export const USD: Currency = { symbol: '$', perUsd: 1 }
143
143
 
144
144
  /** Currencies the config can name by `code` alone. Anything else needs an explicit `symbol`. */
145
145
  const CURRENCY_SYMBOLS: Record<string, string> = {
146
+ AUD: 'A$',
147
+ CAD: 'CA$',
148
+ CHF: 'CHF',
146
149
  CNY: '¥',
147
150
  EUR: '€',
148
151
  GBP: '£',
149
152
  HKD: 'HK$',
150
153
  INR: '₹',
151
- JPY: '¥',
154
+ // ¥ is CNY's by default; JPY wears the prefixed form so the two never trade places.
155
+ JPY: 'JP¥',
152
156
  KRW: '₩',
157
+ NZD: 'NZ$',
153
158
  RMB: '¥',
154
159
  SGD: 'S$',
155
160
  TWD: 'NT$',
@@ -250,26 +255,34 @@ export function currencyFromConfig(text: string | null): CurrencyConfig {
250
255
  }
251
256
  }
252
257
 
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
+ /** The day's rates out of an open.er-api.com payload, positive and finite only. */
259
+ export function ratesFromPayload(payload: unknown): Record<string, number> {
260
+ if (!isRecord(payload) || !isRecord(payload.rates)) return {}
261
+ const rates: Record<string, number> = {}
262
+ for (const [code, rate] of Object.entries(payload.rates)) {
263
+ if (typeof rate === 'number' && Number.isFinite(rate) && rate > 0) rates[code] = rate
264
+ }
265
+ return rates
258
266
  }
259
267
 
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 {
268
+ /** The rates a config file cached from an earlier fetch, and the day they were fetched. */
269
+ export function cachedRates(
270
+ text: string,
271
+ ): { rates: Record<string, number>; fetchedAt: string } | null {
262
272
  let config: unknown
263
273
  try {
264
274
  config = JSON.parse(text)
265
275
  } catch {
266
276
  return null
267
277
  }
268
- if (!isRecord(config)) return null
269
- const perUsd = config.fetchedPerUsd
278
+ if (!isRecord(config) || !isRecord(config.rates)) return null
270
279
  const fetchedAt = config.fetchedAt
271
- if (typeof perUsd !== 'number' || !Number.isFinite(perUsd) || perUsd <= 0) return null
272
- return typeof fetchedAt === 'string' ? { perUsd, fetchedAt } : null
280
+ if (typeof fetchedAt !== 'string') return null
281
+ const rates: Record<string, number> = {}
282
+ for (const [code, rate] of Object.entries(config.rates)) {
283
+ if (typeof rate === 'number' && Number.isFinite(rate) && rate > 0) rates[code] = rate
284
+ }
285
+ return Object.keys(rates).length === 0 ? null : { rates, fetchedAt }
273
286
  }
274
287
 
275
288
  /** Whether a cached rate was fetched today, both dates as `YYYY-MM-DD`. */
@@ -277,13 +290,23 @@ export function cacheIsFresh(fetchedAt: string, today: string): boolean {
277
290
  return fetchedAt.slice(0, 10) === today
278
291
  }
279
292
 
293
+ /** Session-average output tokens per second of measured decode time; null before any measurement. */
294
+ export function avgTokPerSec(outputTokens: number, decodeMs: number): number | null {
295
+ if (decodeMs <= 0) return null
296
+ return outputTokens / (decodeMs / 1000)
297
+ }
298
+
280
299
  /**
281
- * The config file's text with a fetched rate recorded in it, so the next session starts warm.
300
+ * The config file's text with the day's rates recorded in it, so the next session starts warm.
282
301
  *
283
302
  * Unknown keys are kept, and a file that does not parse is returned untouched: the extension has no
284
303
  * business replacing a config it could not read with one it wrote.
285
304
  */
286
- export function withCachedRate(text: string, perUsd: number, fetchedAt: string): string {
305
+ export function withCachedRates(
306
+ text: string,
307
+ rates: Record<string, number>,
308
+ fetchedAt: string,
309
+ ): string {
287
310
  let config: unknown
288
311
  try {
289
312
  config = JSON.parse(text)
@@ -291,7 +314,7 @@ export function withCachedRate(text: string, perUsd: number, fetchedAt: string):
291
314
  return text
292
315
  }
293
316
  if (!isRecord(config)) return text
294
- return `${JSON.stringify({ ...config, fetchedPerUsd: perUsd, fetchedAt }, null, 2)}\n`
317
+ return `${JSON.stringify({ ...config, rates, fetchedAt }, null, 2)}\n`
295
318
  }
296
319
 
297
320
  /** Three decimals, with one trailing zero trimmed so `$0.380` renders as `$0.38`. */