useful-pi-extensions 1.2.0 → 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.2.0",
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,6 +37,10 @@ import {
37
37
  formatTps,
38
38
  isQuietStatus,
39
39
  pair,
40
+ rateFromPayload,
41
+ cachedRate,
42
+ cacheIsFresh,
43
+ withCachedRate,
40
44
  row,
41
45
  shortenPath,
42
46
  ttftDisplay,
@@ -55,6 +59,30 @@ const FALLBACK_TOKENS_PER_CHAR = 0.25
55
59
 
56
60
  /** The status line's own settings file, alongside pi's other per-tool config. */
57
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
+ }
58
86
 
59
87
  /**
60
88
  * Reads the display currency.
@@ -63,22 +91,34 @@ const CONFIG_PATH = join(homedir(), '.pi', 'agent', 'statusline.json')
63
91
  * bug. Editing the file therefore takes effect on `/reload`.
64
92
  */
65
93
  async function loadCurrency(notify: (message: string) => void): Promise<Currency> {
66
- try {
67
- await stat(CONFIG_PATH)
68
- } catch {
69
- // No config file is the normal case, and it means USD.
70
- return USD
71
- }
72
- let text: string
94
+ let text: string | null = null
73
95
  try {
74
96
  text = await readFile(CONFIG_PATH, 'utf8')
75
97
  } catch {
76
- notify('statusline.json exists but could not be read, showing USD')
98
+ // No config file is the normal case, and it means USD.
77
99
  return USD
78
100
  }
79
- const { currency, problem } = currencyFromConfig(text)
101
+ const { currency, pending, problem } = currencyFromConfig(text)
80
102
  if (problem !== null) notify(problem)
81
- 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
82
122
  }
83
123
 
84
124
  /** A content block as providers stream it; every field is read defensively. */
@@ -169,48 +169,129 @@ function isRecord(value: unknown): value is Record<string, unknown> {
169
169
  * @param raw - The `currency` value from a parsed config file.
170
170
  * @returns The currency, or null when the block could not be used.
171
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
+
172
185
  export function parseCurrency(raw: unknown): Currency | null {
173
186
  if (!isRecord(raw)) return null
174
187
  const rate = raw.perUsd
175
188
  if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) return null
176
- const code = typeof raw.code === 'string' ? raw.code.trim().toUpperCase() : ''
177
- const explicit = typeof raw.symbol === 'string' ? raw.symbol : ''
178
- const symbol = explicit || CURRENCY_SYMBOLS[code] || code
179
- return symbol === '' ? null : { symbol, perUsd: rate }
189
+ const identity = currencyIdentity(raw)
190
+ return identity === null ? null : { symbol: identity.symbol, perUsd: rate }
180
191
  }
181
192
 
182
- /** 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. */
183
194
  export interface CurrencyConfig {
184
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
185
198
  problem: string | null
186
199
  }
187
200
 
188
201
  /**
189
202
  * The display currency a config file asks for.
190
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
+ *
191
208
  * @param text - The contents of the config file, or null when it does not exist. Absent is the
192
209
  * normal case and is not a problem; a file that is there but unusable is, because the footer
193
210
  * would otherwise keep showing dollars with nothing to explain it.
194
211
  */
195
212
  export function currencyFromConfig(text: string | null): CurrencyConfig {
196
- if (text === null) return { currency: USD, problem: null }
213
+ if (text === null) return { currency: USD, pending: null, problem: null }
197
214
  let config: unknown
198
215
  try {
199
216
  config = JSON.parse(text)
200
217
  } catch {
201
- 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
+ }
202
223
  }
203
224
  if (!isRecord(config) || !Object.hasOwn(config, 'currency')) {
204
- return { currency: USD, problem: null }
225
+ return { currency: USD, pending: null, problem: null }
205
226
  }
206
- const currency = parseCurrency(config.currency)
207
- if (currency === null) {
227
+ if (!isRecord(config.currency)) {
208
228
  return {
209
229
  currency: USD,
210
- 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,
211
241
  }
212
242
  }
213
- 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`
214
295
  }
215
296
 
216
297
  /** Three decimals, with one trailing zero trimmed so `$0.380` renders as `$0.38`. */