useful-pi-extensions 1.2.0 → 1.4.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.4.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
|
|
|
@@ -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,6 +38,10 @@ import {
|
|
|
37
38
|
formatTps,
|
|
38
39
|
isQuietStatus,
|
|
39
40
|
pair,
|
|
41
|
+
cachedRates,
|
|
42
|
+
ratesFromPayload,
|
|
43
|
+
cacheIsFresh,
|
|
44
|
+
withCachedRates,
|
|
40
45
|
row,
|
|
41
46
|
shortenPath,
|
|
42
47
|
ttftDisplay,
|
|
@@ -55,30 +60,73 @@ const FALLBACK_TOKENS_PER_CHAR = 0.25
|
|
|
55
60
|
|
|
56
61
|
/** The status line's own settings file, alongside pi's other per-tool config. */
|
|
57
62
|
const CONFIG_PATH = join(homedir(), '.pi', 'agent', 'statusline.json')
|
|
63
|
+
/** Free, keyless, and one request returns every currency — so the cache serves instant switching. */
|
|
64
|
+
const RATES_URL = 'https://open.er-api.com/v6/latest/USD'
|
|
65
|
+
const FETCH_TIMEOUT_MS = 5000
|
|
66
|
+
|
|
67
|
+
function today(): string {
|
|
68
|
+
const now = new Date()
|
|
69
|
+
const month = String(now.getMonth() + 1).padStart(2, '0')
|
|
70
|
+
const day = String(now.getDate()).padStart(2, '0')
|
|
71
|
+
return `${now.getFullYear()}-${month}-${day}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function fetchRates(): Promise<Record<string, number> | null> {
|
|
75
|
+
const controller = new AbortController()
|
|
76
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
|
77
|
+
try {
|
|
78
|
+
const response = await fetch(RATES_URL, { signal: controller.signal })
|
|
79
|
+
if (!response.ok) return null
|
|
80
|
+
const rates = ratesFromPayload(await response.json())
|
|
81
|
+
return Object.keys(rates).length === 0 ? null : rates
|
|
82
|
+
} catch {
|
|
83
|
+
return null
|
|
84
|
+
} finally {
|
|
85
|
+
clearTimeout(timer)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
58
88
|
|
|
59
89
|
/**
|
|
60
90
|
* Reads the display currency.
|
|
61
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
|
+
*
|
|
62
97
|
* Called once per session on purpose: a footer that stat'ed a file on every frame would be its own
|
|
63
98
|
* bug. Editing the file therefore takes effect on `/reload`.
|
|
64
99
|
*/
|
|
65
100
|
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
|
|
101
|
+
let text: string | null = null
|
|
73
102
|
try {
|
|
74
103
|
text = await readFile(CONFIG_PATH, 'utf8')
|
|
75
104
|
} catch {
|
|
76
|
-
|
|
105
|
+
// No config file is the normal case, and it means USD.
|
|
77
106
|
return USD
|
|
78
107
|
}
|
|
79
|
-
const { currency, problem } = currencyFromConfig(text)
|
|
108
|
+
const { currency, pending, problem } = currencyFromConfig(text)
|
|
80
109
|
if (problem !== null) notify(problem)
|
|
81
|
-
return currency
|
|
110
|
+
if (pending === null) return currency
|
|
111
|
+
|
|
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 }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const fetched = await fetchRates()
|
|
119
|
+
if (fetched !== null) {
|
|
120
|
+
try {
|
|
121
|
+
await writeFile(CONFIG_PATH, withCachedRates(text, fetched, today()))
|
|
122
|
+
} catch {
|
|
123
|
+
// The session still runs on the fetched rate; only tomorrow's warm start is lost.
|
|
124
|
+
}
|
|
125
|
+
return { symbol: pending.symbol, perUsd: fetched[pending.code] ?? USD.perUsd }
|
|
126
|
+
}
|
|
127
|
+
if (known !== undefined) return { symbol: pending.symbol, perUsd: known }
|
|
128
|
+
notify(`could not fetch a ${pending.code} rate, showing USD`)
|
|
129
|
+
return USD
|
|
82
130
|
}
|
|
83
131
|
|
|
84
132
|
/** A content block as providers stream it; every field is read defensively. */
|
|
@@ -191,6 +239,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
191
239
|
let chars = 0
|
|
192
240
|
let requestAt: number | null = null
|
|
193
241
|
let firstTokenAt: number | null = null
|
|
242
|
+
let totalDecodeMs = 0
|
|
194
243
|
let ticker: ReturnType<typeof setInterval> | null = null
|
|
195
244
|
let windowAt = 0
|
|
196
245
|
let windowTokens = 0
|
|
@@ -241,6 +290,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
241
290
|
render(width: number): string[] {
|
|
242
291
|
const usage = ctx.getContextUsage()
|
|
243
292
|
const totals = collectTotals(ctx)
|
|
293
|
+
const avg = avgTokPerSec(totals.output, totalDecodeMs)
|
|
244
294
|
const row1 = contextRow(
|
|
245
295
|
theme,
|
|
246
296
|
width,
|
|
@@ -269,12 +319,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
269
319
|
if (reading.ttftMs !== null)
|
|
270
320
|
row2Parts.push(pair(theme, 'TTFT', formatLatency(reading.ttftMs), 'muted'))
|
|
271
321
|
row2Parts.push(
|
|
272
|
-
|
|
273
|
-
|
|
322
|
+
pair(
|
|
323
|
+
theme,
|
|
324
|
+
'Last',
|
|
274
325
|
`${reading.exact ? '' : '~'}${formatTps(reading.rate)} tok/s`,
|
|
326
|
+
reading.exact ? 'success' : 'dim',
|
|
275
327
|
),
|
|
276
328
|
)
|
|
277
329
|
}
|
|
330
|
+
if (avg !== null) row2Parts.push(pair(theme, 'Avg', `${formatTps(avg)} tok/s`, 'muted'))
|
|
278
331
|
const row2Right = row2Parts.join(theme.fg('dim', ' · '))
|
|
279
332
|
|
|
280
333
|
const branch = footerData.getGitBranch()
|
|
@@ -334,6 +387,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
334
387
|
ratio = seedRatio(ctx)
|
|
335
388
|
reading = null
|
|
336
389
|
requestAt = null
|
|
390
|
+
totalDecodeMs = 0
|
|
337
391
|
stopTicker()
|
|
338
392
|
resetStream()
|
|
339
393
|
currency = await loadCurrency((message) => ctx.ui.notify(message, 'warning'))
|
|
@@ -415,8 +469,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
415
469
|
const decodeMs = firstTokenAt !== null ? Date.now() - firstTokenAt : 0
|
|
416
470
|
const measured = ttftMs(requestAt, firstTokenAt)
|
|
417
471
|
const tokens = output > 0 ? output : totalChars * (ratio ?? FALLBACK_TOKENS_PER_CHAR)
|
|
418
|
-
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
|
|
419
475
|
publish((tokens / decodeMs) * 1000, output > 0, measured)
|
|
476
|
+
}
|
|
420
477
|
// Null it with the stream: a request that has produced its message is no longer in flight, and
|
|
421
478
|
// a stale anchor would let the live branch count against nothing until the next turn.
|
|
422
479
|
requestAt = null
|
|
@@ -169,48 +169,147 @@ 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
|
+
/** The day's rates out of an open.er-api.com payload, positive and finite only. */
|
|
254
|
+
export function ratesFromPayload(payload: unknown): Record<string, number> {
|
|
255
|
+
if (!isRecord(payload) || !isRecord(payload.rates)) return {}
|
|
256
|
+
const rates: Record<string, number> = {}
|
|
257
|
+
for (const [code, rate] of Object.entries(payload.rates)) {
|
|
258
|
+
if (typeof rate === 'number' && Number.isFinite(rate) && rate > 0) rates[code] = rate
|
|
259
|
+
}
|
|
260
|
+
return rates
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The rates a config file cached from an earlier fetch, and the day they were fetched. */
|
|
264
|
+
export function cachedRates(
|
|
265
|
+
text: string,
|
|
266
|
+
): { rates: Record<string, number>; fetchedAt: string } | null {
|
|
267
|
+
let config: unknown
|
|
268
|
+
try {
|
|
269
|
+
config = JSON.parse(text)
|
|
270
|
+
} catch {
|
|
271
|
+
return null
|
|
272
|
+
}
|
|
273
|
+
if (!isRecord(config) || !isRecord(config.rates)) return null
|
|
274
|
+
const fetchedAt = config.fetchedAt
|
|
275
|
+
if (typeof fetchedAt !== 'string') return null
|
|
276
|
+
const rates: Record<string, number> = {}
|
|
277
|
+
for (const [code, rate] of Object.entries(config.rates)) {
|
|
278
|
+
if (typeof rate === 'number' && Number.isFinite(rate) && rate > 0) rates[code] = rate
|
|
279
|
+
}
|
|
280
|
+
return Object.keys(rates).length === 0 ? null : { rates, fetchedAt }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Whether a cached rate was fetched today, both dates as `YYYY-MM-DD`. */
|
|
284
|
+
export function cacheIsFresh(fetchedAt: string, today: string): boolean {
|
|
285
|
+
return fetchedAt.slice(0, 10) === today
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Session-average output tokens per second of measured decode time; null before any measurement. */
|
|
289
|
+
export function avgTokPerSec(outputTokens: number, decodeMs: number): number | null {
|
|
290
|
+
if (decodeMs <= 0) return null
|
|
291
|
+
return outputTokens / (decodeMs / 1000)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The config file's text with the day's rates recorded in it, so the next session starts warm.
|
|
296
|
+
*
|
|
297
|
+
* Unknown keys are kept, and a file that does not parse is returned untouched: the extension has no
|
|
298
|
+
* business replacing a config it could not read with one it wrote.
|
|
299
|
+
*/
|
|
300
|
+
export function withCachedRates(
|
|
301
|
+
text: string,
|
|
302
|
+
rates: Record<string, number>,
|
|
303
|
+
fetchedAt: string,
|
|
304
|
+
): string {
|
|
305
|
+
let config: unknown
|
|
306
|
+
try {
|
|
307
|
+
config = JSON.parse(text)
|
|
308
|
+
} catch {
|
|
309
|
+
return text
|
|
310
|
+
}
|
|
311
|
+
if (!isRecord(config)) return text
|
|
312
|
+
return `${JSON.stringify({ ...config, rates, fetchedAt }, null, 2)}\n`
|
|
214
313
|
}
|
|
215
314
|
|
|
216
315
|
/** Three decimals, with one trailing zero trimmed so `$0.380` renders as `$0.38`. */
|