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.2",
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
- /** Where throughput metrics wait out a /reload: keyed by session file, so a reload restores. */
82
- /** One state file per session, so two concurrent sessions cannot clobber each other. */
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 = blockChars(entry.message.content)
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: usage?.contextWindow ?? shown?.contextWindow ?? ctx.model?.contextWindow ?? 0,
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 = window > 0 ? Math.max(0, window - used - (reserveTokens ?? 0)) : null
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
- // A run that ends without a first token (abort, provider error) must not leave a clock counting
689
- // against a request that is no longer in flight.
690
- pi.on('turn_end', async () => {
691
- requestAt = null
692
- stopTicker()
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
- pi.on('agent_settled', async () => {
701
- requestAt = null
702
- stopTicker()
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 = blockChars(message.content)
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
- /** Characters of message content: strings, text blocks, tool calls and image placeholders. */
632
- export function contentChars(content: unknown): number {
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 += 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