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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
177
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
207
|
-
if (currency === null) {
|
|
227
|
+
if (!isRecord(config.currency)) {
|
|
208
228
|
return {
|
|
209
229
|
currency: USD,
|
|
210
|
-
|
|
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
|
-
|
|
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`. */
|