useful-pi-extensions 1.11.2 → 1.11.3
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.11.
|
|
3
|
+
"version": "1.11.3",
|
|
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, plus an opt-in context breakdown panel.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -32,6 +32,7 @@ import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'
|
|
|
32
32
|
import {
|
|
33
33
|
avgTokPerSec,
|
|
34
34
|
breakdownPanel,
|
|
35
|
+
contentChars,
|
|
35
36
|
contextRow,
|
|
36
37
|
currencyFromConfig,
|
|
37
38
|
detailFromConfig,
|
|
@@ -68,7 +69,6 @@ const TICK_MS = 250
|
|
|
68
69
|
/** Pi's own estimateTokens() heuristic, used only until a real ratio is known. */
|
|
69
70
|
const FALLBACK_TOKENS_PER_CHAR = 0.25
|
|
70
71
|
|
|
71
|
-
/** The status line's own settings file, alongside pi's other per-tool config. */
|
|
72
72
|
/** Everything this extension keeps lives in one folder, not loose files relying on name prefixes. */
|
|
73
73
|
const STATUSLINE_DIR = join(homedir(), '.pi', 'agent', 'statusline')
|
|
74
74
|
const CONFIG_PATH = join(STATUSLINE_DIR, 'config.json')
|
|
@@ -78,8 +78,10 @@ const LEGACY_CONFIG = join(homedir(), '.pi', 'agent', 'statusline.json')
|
|
|
78
78
|
const RATES_URL = 'https://open.er-api.com/v6/latest/USD'
|
|
79
79
|
/** Every session on this machine, for the day-cost total that spans projects and models. */
|
|
80
80
|
const SESSIONS_DIR = join(homedir(), '.pi', 'agent', 'sessions')
|
|
81
|
-
/**
|
|
82
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Where throughput metrics wait out a /reload: one state file per session, keyed by session file,
|
|
83
|
+
* so a reload restores its own and two concurrent sessions cannot clobber each other.
|
|
84
|
+
*/
|
|
83
85
|
const STATE_DIR = join(STATUSLINE_DIR, 'state')
|
|
84
86
|
const LEGACY_STATE = join(homedir(), '.pi', 'agent', 'statusline-state.json')
|
|
85
87
|
/** The 1.8 single-file state: one bucket every session shared, also migrated to per-session. */
|
|
@@ -120,17 +122,6 @@ async function fetchRates(): Promise<Record<string, number> | null> {
|
|
|
120
122
|
}
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
/**
|
|
124
|
-
* Reads the display currency.
|
|
125
|
-
*
|
|
126
|
-
* `perUsd` is a pinned rate and wins untouched; a code without one resolves through the cached rate
|
|
127
|
-
* table — today's table answers instantly, a stale one answers while a fresh one is fetched, and
|
|
128
|
-
* only a first-ever failure speaks up. The whole table is cached because one request returns every
|
|
129
|
-
* currency, which is also what makes switching codes instant and offline.
|
|
130
|
-
*
|
|
131
|
-
* Called once per session on purpose: a footer that stat'ed a file on every frame would be its own
|
|
132
|
-
* bug. Editing the file therefore takes effect on `/reload`.
|
|
133
|
-
*/
|
|
134
125
|
function startOfToday(): number {
|
|
135
126
|
const start = new Date()
|
|
136
127
|
start.setHours(0, 0, 0, 0)
|
|
@@ -183,7 +174,6 @@ async function pruneState(): Promise<void> {
|
|
|
183
174
|
const filePath = join(STATE_DIR, name)
|
|
184
175
|
const stats = await stat(filePath).catch(() => null)
|
|
185
176
|
if (stats !== null && stats.mtimeMs < cutoff) await unlink(filePath).catch(() => {})
|
|
186
|
-
return null
|
|
187
177
|
}),
|
|
188
178
|
)
|
|
189
179
|
} catch {
|
|
@@ -253,6 +243,17 @@ async function sumOtherTodaysCost(currentFile: string | null, since: number): Pr
|
|
|
253
243
|
return costs.reduce((sum, value) => sum + value, 0)
|
|
254
244
|
}
|
|
255
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Reads the display currency.
|
|
248
|
+
*
|
|
249
|
+
* `perUsd` is a pinned rate and wins untouched; a code without one resolves through the cached rate
|
|
250
|
+
* table — today's table answers instantly, a stale one answers while a fresh one is fetched, and
|
|
251
|
+
* only a first-ever failure speaks up. The whole table is cached because one request returns every
|
|
252
|
+
* currency, which is also what makes switching codes instant and offline.
|
|
253
|
+
*
|
|
254
|
+
* Called once per session on purpose: a footer that stat'ed a file on every frame would be its own
|
|
255
|
+
* bug. Editing the file therefore takes effect on `/reload`.
|
|
256
|
+
*/
|
|
256
257
|
async function loadCurrency(notify: (message: string) => void): Promise<Currency> {
|
|
257
258
|
let text: string | null = null
|
|
258
259
|
try {
|
|
@@ -287,37 +288,6 @@ async function loadCurrency(notify: (message: string) => void): Promise<Currency
|
|
|
287
288
|
return USD
|
|
288
289
|
}
|
|
289
290
|
|
|
290
|
-
/** A content block as providers stream it; every field is read defensively. */
|
|
291
|
-
type ContentBlock = {
|
|
292
|
-
type?: unknown
|
|
293
|
-
text?: unknown
|
|
294
|
-
thinking?: unknown
|
|
295
|
-
name?: unknown
|
|
296
|
-
arguments?: unknown
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function isContentBlock(value: unknown): value is ContentBlock {
|
|
300
|
-
return typeof value === 'object' && value !== null
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function blockChars(content: unknown): number {
|
|
304
|
-
if (typeof content === 'string') return content.length
|
|
305
|
-
if (!Array.isArray(content)) return 0
|
|
306
|
-
|
|
307
|
-
let chars = 0
|
|
308
|
-
for (const raw of content as unknown[]) {
|
|
309
|
-
if (!isContentBlock(raw)) continue
|
|
310
|
-
const { type, text, thinking, name, arguments: args } = raw
|
|
311
|
-
if (type === 'text' && typeof text === 'string') chars += text.length
|
|
312
|
-
else if (type === 'thinking' && typeof thinking === 'string') chars += thinking.length
|
|
313
|
-
else if (type === 'toolCall') {
|
|
314
|
-
const nameLength = typeof name === 'string' ? name.length : 0
|
|
315
|
-
chars += nameLength + JSON.stringify(args ?? {}).length
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
return chars
|
|
319
|
-
}
|
|
320
|
-
|
|
321
291
|
interface Totals {
|
|
322
292
|
input: number
|
|
323
293
|
output: number
|
|
@@ -381,13 +351,18 @@ function collectTotals(ctx: ExtensionContext, since: number): Totals {
|
|
|
381
351
|
return totals
|
|
382
352
|
}
|
|
383
353
|
|
|
354
|
+
/** Chars for the tokens-per-char ratio: images never stream as text, so they must not dilute it. */
|
|
355
|
+
function ratioChars(content: unknown): number {
|
|
356
|
+
return contentChars(content, 0)
|
|
357
|
+
}
|
|
358
|
+
|
|
384
359
|
/** Tokens per character, averaged over recent assistant messages with provider usage. */
|
|
385
360
|
function seedRatio(ctx: ExtensionContext): number | null {
|
|
386
361
|
const samples: number[] = []
|
|
387
362
|
for (const entry of ctx.sessionManager.getBranch().toReversed()) {
|
|
388
363
|
if (entry.type !== 'message' || entry.message.role !== 'assistant') continue
|
|
389
364
|
const output = entry.message.usage.output
|
|
390
|
-
const chars =
|
|
365
|
+
const chars = ratioChars(entry.message.content)
|
|
391
366
|
if (output <= 0 || chars <= 0) continue
|
|
392
367
|
samples.push(output / chars)
|
|
393
368
|
if (samples.length >= 5) break
|
|
@@ -522,6 +497,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
522
497
|
}, TICK_MS)
|
|
523
498
|
}
|
|
524
499
|
|
|
500
|
+
/**
|
|
501
|
+
* Drops the in-flight anchor and its clock. A run that ends without a first token (abort,
|
|
502
|
+
* provider error) must not leave a clock counting against a request that is no longer in flight.
|
|
503
|
+
*/
|
|
504
|
+
function clearInFlightRequest(): void {
|
|
505
|
+
requestAt = null
|
|
506
|
+
stopTicker()
|
|
507
|
+
}
|
|
508
|
+
|
|
525
509
|
/**
|
|
526
510
|
* Writes the throughput metrics so a /reload can restore them.
|
|
527
511
|
*
|
|
@@ -586,13 +570,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
586
570
|
const shown = trusted ?? lastKnownUsage
|
|
587
571
|
const totals = collectTotals(ctx, startOfToday())
|
|
588
572
|
const avg = avgTokPerSec(totalMeasuredOutput, totalDecodeMs)
|
|
573
|
+
const contextWindow =
|
|
574
|
+
usage?.contextWindow ?? shown?.contextWindow ?? ctx.model?.contextWindow ?? 0
|
|
589
575
|
const row1 = contextRow(
|
|
590
576
|
theme,
|
|
591
577
|
width,
|
|
592
578
|
{
|
|
593
579
|
percent: shown?.percent ?? 0,
|
|
594
580
|
tokens: shown?.tokens ?? 0,
|
|
595
|
-
window:
|
|
581
|
+
window: contextWindow,
|
|
596
582
|
input: totals.input,
|
|
597
583
|
output: totals.output,
|
|
598
584
|
cacheHitRate: totals.cacheHitRate,
|
|
@@ -606,10 +592,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
606
592
|
// row. The concise footer renders by default; ctrl+q or /breakdown opens the panel.
|
|
607
593
|
let breakdown: string[] | null = null
|
|
608
594
|
if (detailVisible) {
|
|
609
|
-
const window =
|
|
610
|
-
usage?.contextWindow ?? shown?.contextWindow ?? ctx.model?.contextWindow ?? 0
|
|
611
595
|
const used = shown?.tokens ?? 0
|
|
612
|
-
const free =
|
|
596
|
+
const free =
|
|
597
|
+
contextWindow > 0 ? Math.max(0, contextWindow - used - (reserveTokens ?? 0)) : null
|
|
613
598
|
breakdown = breakdownPanel(theme, width, {
|
|
614
599
|
messages: messagesFor(ctx),
|
|
615
600
|
promptParts,
|
|
@@ -617,7 +602,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
617
602
|
reserve: reserveTokens,
|
|
618
603
|
free,
|
|
619
604
|
usedPercent: shown?.percent ?? null,
|
|
620
|
-
window,
|
|
605
|
+
window: contextWindow,
|
|
621
606
|
used,
|
|
622
607
|
})
|
|
623
608
|
}
|
|
@@ -685,22 +670,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
685
670
|
})
|
|
686
671
|
}
|
|
687
672
|
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
pi.on('agent_end', async () => {
|
|
696
|
-
requestAt = null
|
|
697
|
-
stopTicker()
|
|
673
|
+
// TTFT's request anchor, from the agent loop in pi's docs: turn_start opens the LLM call and
|
|
674
|
+
// before_provider_request is the last thing pi does before the wire. message_start is not that
|
|
675
|
+
// moment, and anchoring there made a first token that arrived early read as a clamped 0ms.
|
|
676
|
+
pi.on('turn_start', clearInFlightRequest)
|
|
677
|
+
pi.on('before_provider_request', () => {
|
|
678
|
+
requestAt = Date.now()
|
|
679
|
+
startTicker()
|
|
698
680
|
})
|
|
699
681
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
682
|
+
// Every way a run can retire retires the in-flight request with it.
|
|
683
|
+
pi.on('turn_end', clearInFlightRequest)
|
|
684
|
+
pi.on('agent_end', clearInFlightRequest)
|
|
685
|
+
pi.on('agent_settled', clearInFlightRequest)
|
|
704
686
|
|
|
705
687
|
// The prompt and the tool list are inputs to the breakdown line, and both are settled by the
|
|
706
688
|
// time this fires: earlier handlers have chained their changes, so what is captured here is
|
|
@@ -750,27 +732,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
750
732
|
resetStream()
|
|
751
733
|
})
|
|
752
734
|
|
|
753
|
-
// TTFT's request anchor, from the agent loop in pi's docs: turn_start opens the LLM call and
|
|
754
|
-
// before_provider_request is the last thing pi does before the wire. message_start is not that
|
|
755
|
-
// moment, and anchoring there made a first token that arrived early read as a clamped 0ms.
|
|
756
|
-
pi.on('turn_start', async () => {
|
|
757
|
-
requestAt = null
|
|
758
|
-
})
|
|
759
|
-
|
|
760
|
-
pi.on('before_provider_request', async () => {
|
|
761
|
-
requestAt = Date.now()
|
|
762
|
-
})
|
|
763
|
-
|
|
764
|
-
pi.on('turn_start', async () => {
|
|
765
|
-
requestAt = null
|
|
766
|
-
stopTicker()
|
|
767
|
-
})
|
|
768
|
-
|
|
769
|
-
pi.on('before_provider_request', async () => {
|
|
770
|
-
requestAt = Date.now()
|
|
771
|
-
startTicker()
|
|
772
|
-
})
|
|
773
|
-
|
|
774
735
|
pi.on('message_update', async (event) => {
|
|
775
736
|
const delta = event.assistantMessageEvent
|
|
776
737
|
// Narrowing on the discriminant is what keeps the payload typed; membership in
|
|
@@ -811,7 +772,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
811
772
|
|
|
812
773
|
const message = event.message as { content: unknown; usage?: { output?: number } }
|
|
813
774
|
const output = message.usage?.output ?? 0
|
|
814
|
-
const totalChars =
|
|
775
|
+
const totalChars = ratioChars(message.content)
|
|
815
776
|
if (output > 0 && totalChars > 0) {
|
|
816
777
|
const sample = output / totalChars
|
|
817
778
|
ratio = ratio === null ? sample : (ratio + sample) / 2
|
|
@@ -192,15 +192,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
192
192
|
return typeof value === 'object' && value !== null
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
/**
|
|
196
|
-
* Validates a `currency` block.
|
|
197
|
-
*
|
|
198
|
-
* A rate that is missing, zero, negative or not a number is refused rather than defaulted: a
|
|
199
|
-
* confident wrong amount is worse than the unconverted one the caller falls back to.
|
|
200
|
-
*
|
|
201
|
-
* @param raw - The `currency` value from a parsed config file.
|
|
202
|
-
* @returns The currency, or null when the block could not be used.
|
|
203
|
-
*/
|
|
204
195
|
/** The identity a currency block asks for: the API code and the symbol to print. */
|
|
205
196
|
interface CurrencyIdentity {
|
|
206
197
|
code: string
|
|
@@ -214,6 +205,15 @@ function currencyIdentity(raw: Record<string, unknown>): CurrencyIdentity | null
|
|
|
214
205
|
return symbol === '' ? null : { code, symbol }
|
|
215
206
|
}
|
|
216
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Validates a `currency` block.
|
|
210
|
+
*
|
|
211
|
+
* A rate that is missing, zero, negative or not a number is refused rather than defaulted: a
|
|
212
|
+
* confident wrong amount is worse than the unconverted one the caller falls back to.
|
|
213
|
+
*
|
|
214
|
+
* @param raw - The `currency` value from a parsed config file.
|
|
215
|
+
* @returns The currency, or null when the block could not be used.
|
|
216
|
+
*/
|
|
217
217
|
export function parseCurrency(raw: unknown): Currency | null {
|
|
218
218
|
if (!isRecord(raw)) return null
|
|
219
219
|
const rate = raw.perUsd
|
|
@@ -349,7 +349,6 @@ export function withCachedRates(
|
|
|
349
349
|
return `${JSON.stringify({ ...config, rates, fetchedAt }, null, 2)}\n`
|
|
350
350
|
}
|
|
351
351
|
|
|
352
|
-
/** Two decimals, with padding zeros trimmed so `$0.30` renders as `$0.3`. */
|
|
353
352
|
/**
|
|
354
353
|
* Two decimals, with the padding zeros trimmed so `$0.30` renders as `$0.3`.
|
|
355
354
|
*
|
|
@@ -628,8 +627,14 @@ export function tokensFromChars(chars: number): number {
|
|
|
628
627
|
/** Images carry this many estimated characters each, pi's own ESTIMATED_IMAGE_CHARS. */
|
|
629
628
|
const IMAGE_CHARS = 4800
|
|
630
629
|
|
|
631
|
-
/**
|
|
632
|
-
|
|
630
|
+
/**
|
|
631
|
+
* Characters of message content: strings, text blocks, tool calls and image placeholders.
|
|
632
|
+
*
|
|
633
|
+
* @param imageChars - What one image block counts for. The breakdown panel pays pi's full estimate;
|
|
634
|
+
* the stream ratio passes 0, because images never arrive as streamed text and would dilute a
|
|
635
|
+
* tokens-per-character reading with phantom characters.
|
|
636
|
+
*/
|
|
637
|
+
export function contentChars(content: unknown, imageChars = IMAGE_CHARS): number {
|
|
633
638
|
if (typeof content === 'string') return content.length
|
|
634
639
|
if (!Array.isArray(content)) return 0
|
|
635
640
|
let chars = 0
|
|
@@ -638,7 +643,7 @@ export function contentChars(content: unknown): number {
|
|
|
638
643
|
if (raw.type === 'text' && typeof raw.text === 'string') chars += raw.text.length
|
|
639
644
|
else if (raw.type === 'thinking' && typeof raw.thinking === 'string')
|
|
640
645
|
chars += raw.thinking.length
|
|
641
|
-
else if (raw.type === 'image') chars +=
|
|
646
|
+
else if (raw.type === 'image') chars += imageChars
|
|
642
647
|
else if (raw.type === 'toolCall') {
|
|
643
648
|
const name = typeof raw.name === 'string' ? raw.name.length : 0
|
|
644
649
|
let args = 0
|