useful-pi-extensions 1.5.0 → 1.6.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.5.0",
3
+ "version": "1.6.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",
@@ -8,7 +8,7 @@ Replaces pi's footer with a labelled two-row one. Every value carries a word, so
8
8
  decoded from a symbol or remembered from a legend.
9
9
 
10
10
  ```
11
- Context █████████▍░░░░░░░░░░ 47.1% 471k / 1.0M Input 194k · Output 89k · Cache hit 99.9% · Cost $0.229
11
+ Context █████████▍░░░░░░░░░░ 47% 471k / 1.0M Input 194k · Output 89k · Cache hit 99.9% · Cost $0.229 · Today $1.63
12
12
  ~/.pi (master) deepseek-flash · Effort high · TTFT 482ms · 729 tok/s
13
13
  LSP Active: typescript
14
14
  ```
@@ -19,17 +19,18 @@ columns only the meter is left. Every step is a whole value — a number is neve
19
19
 
20
20
  ## What each part is
21
21
 
22
- | Part | Meaning |
23
- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
24
- | `Context` + meter + `47.1%` | Share of the model's context window in use. The fill turns `warning` above 70% and `error` above 90%, the same thresholds pi's shipped footer uses, and the percentage changes color with it |
25
- | `471k / 1.0M` | Absolute context tokens over the window size. The first thing dropped when the terminal is narrow |
26
- | `Input` / `Output` | Session prompt and completion tokens. `Input` counts the prompt tokens that were neither read from nor written to cache, because pi reports those two separately in the same `usage` object |
27
- | `Cache hit` | The latest turn's cache hit rate, `cacheRead / (input + cacheRead + cacheWrite)` |
28
- | `Cost` | Session cost, in USD unless a config file names another currency (see below) |
29
- | Effort | The active thinking level |
30
- | `TTFT` | Time from request dispatch to the first streamed token |
31
- | `tok/s` | Decode throughput, i.e. output tokens per second of decode time |
32
- | Last line | Other extensions' `ctx.ui.setStatus()` entries, so they do not silently disappear |
22
+ | Part | Meaning |
23
+ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
24
+ | `Context` + meter + `47%` | Share of the model's context window in use. The fill turns `warning` above 70% and `error` above 90%, the same thresholds pi's shipped footer uses, and the percentage changes color with it |
25
+ | `471k / 1.0M` | Absolute context tokens over the window size. The first thing dropped when the terminal is narrow |
26
+ | `Input` / `Output` | Session prompt and completion tokens. `Input` counts the prompt tokens that were neither read from nor written to cache, because pi reports those two separately in the same `usage` object |
27
+ | `Cache hit` | The latest turn's cache hit rate, `cacheRead / (input + cacheRead + cacheWrite)` |
28
+ | `Cost` | Session cost, in USD unless a config file names another currency (see below) |
29
+ | `Today` | Today's running cost across every project, session and model on this machine |
30
+ | Effort | The active thinking level |
31
+ | `TTFT` | Time from request dispatch to the first streamed token |
32
+ | `tok/s` | Decode throughput, i.e. output tokens per second of decode time |
33
+ | Last line | Other extensions' `ctx.ui.setStatus()` entries, so they do not silently disappear |
33
34
 
34
35
  ## The meter
35
36
 
@@ -7,7 +7,7 @@
7
7
  替换 pi 的 footer,改成带文字标签的两行。每个值都带一个词,不需要靠符号猜、也不需要记图例。
8
8
 
9
9
  ```
10
- Context █████████▍░░░░░░░░░░ 47.1% 471k / 1.0M Input 194k · Output 89k · Cache hit 99.9% · Cost $0.229
10
+ Context █████████▍░░░░░░░░░░ 47% 471k / 1.0M Input 194k · Output 89k · Cache hit 99.9% · Cost $0.229 · Today $1.63
11
11
  ~/.pi (master) deepseek-flash · Effort high · TTFT 482ms · 729 tok/s
12
12
  LSP Active: typescript
13
13
  ```
@@ -25,6 +25,7 @@ LSP Active: typescript
25
25
  | `Input` / `Output` | 会话的输入与输出 token 量。`Input` 指既没命中缓存、也没写入缓存的那部分 prompt token,因为 pi 把这两种情况在同一个 `usage` 对象里分开报 |
26
26
  | `Cache hit` | 最近一轮的缓存命中率,`cacheRead / (input + cacheRead + cacheWrite)` |
27
27
  | `Cost` | 会话花费,默认美元;配置了其他币种则换算显示(见下) |
28
+ | `Today` | 当天跨项目、跨会话、跨模型的累计花费 |
28
29
  | Effort | 当前思考等级 |
29
30
  | `TTFT` | 从发出请求到第一个流式 token 的耗时 |
30
31
  | `tok/s` | 解码吞吐,即每秒解码时间产出的输出 token 数 |
@@ -18,7 +18,8 @@
18
18
  * stepStartTime; decodeMs = completedTime - firstTokenTime; tok/s = usage.output / (decodeMs / 1000)
19
19
  */
20
20
 
21
- import { readFile, writeFile } from 'node:fs/promises'
21
+ import { readFile, readdir, writeFile } from 'node:fs/promises'
22
+ import { stat } from 'node:fs/promises'
22
23
  import { homedir } from 'node:os'
23
24
  import { join } from 'node:path'
24
25
 
@@ -62,6 +63,8 @@ const FALLBACK_TOKENS_PER_CHAR = 0.25
62
63
  const CONFIG_PATH = join(homedir(), '.pi', 'agent', 'statusline.json')
63
64
  /** Free, keyless, and one request returns every currency — so the cache serves instant switching. */
64
65
  const RATES_URL = 'https://open.er-api.com/v6/latest/USD'
66
+ /** Every session on this machine, for the day-cost total that spans projects and models. */
67
+ const SESSIONS_DIR = join(homedir(), '.pi', 'agent', 'sessions')
65
68
  const FETCH_TIMEOUT_MS = 5000
66
69
 
67
70
  function today(): string {
@@ -97,6 +100,78 @@ async function fetchRates(): Promise<Record<string, number> | null> {
97
100
  * Called once per session on purpose: a footer that stat'ed a file on every frame would be its own
98
101
  * bug. Editing the file therefore takes effect on `/reload`.
99
102
  */
103
+ function startOfToday(): number {
104
+ const start = new Date()
105
+ start.setHours(0, 0, 0, 0)
106
+ return start.getTime()
107
+ }
108
+
109
+ function isRecord(value: unknown): value is Record<string, unknown> {
110
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
111
+ }
112
+
113
+ /** Today's provider cost of one session-file line, or null when the line bills nothing today. */
114
+ function entryCost(line: string, since: number): number | null {
115
+ if (!line.includes('"usage"')) return null
116
+ let entry: unknown
117
+ try {
118
+ entry = JSON.parse(line)
119
+ } catch {
120
+ return null
121
+ }
122
+ if (!isRecord(entry) || typeof entry.timestamp !== 'string') return null
123
+ if (Date.parse(entry.timestamp) < since) return null
124
+ if (!isRecord(entry.usage) || !isRecord(entry.usage.cost)) return null
125
+ const total = entry.usage.cost.total
126
+ return typeof total === 'number' && Number.isFinite(total) ? total : null
127
+ }
128
+
129
+ /**
130
+ * Today's cost across every other session on this machine.
131
+ *
132
+ * Files not touched today are skipped unread, and this session's own file is skipped because its
133
+ * cost is already live in totals.cost — counting both would double a resumed session.
134
+ */
135
+ async function sumOtherTodaysCost(currentFile: string | null, since: number): Promise<number> {
136
+ let dirs: string[] = []
137
+ try {
138
+ dirs = await readdir(SESSIONS_DIR)
139
+ } catch {
140
+ return 0
141
+ }
142
+ const lists = await Promise.all(
143
+ dirs.map(async (dir) => {
144
+ const dirPath = join(SESSIONS_DIR, dir)
145
+ let names: string[] = []
146
+ try {
147
+ names = await readdir(dirPath)
148
+ } catch {
149
+ return []
150
+ }
151
+ return names
152
+ .map((name) => join(dirPath, name))
153
+ .filter((path) => path.endsWith('.jsonl') && path !== currentFile)
154
+ }),
155
+ )
156
+ const costs = await Promise.all(
157
+ lists.flat().map(async (path) => {
158
+ try {
159
+ if ((await stat(path)).mtimeMs < since) return 0
160
+ } catch {
161
+ return 0
162
+ }
163
+ const text = await readFile(path, 'utf8').catch(() => '')
164
+ let total = 0
165
+ for (const line of text.split('\n')) {
166
+ const cost = entryCost(line, since)
167
+ if (cost !== null) total += cost
168
+ }
169
+ return total
170
+ }),
171
+ )
172
+ return costs.reduce((sum, value) => sum + value, 0)
173
+ }
174
+
100
175
  async function loadCurrency(notify: (message: string) => void): Promise<Currency> {
101
176
  let text: string | null = null
102
177
  try {
@@ -167,6 +242,7 @@ interface Totals {
167
242
  cacheWrite: number
168
243
  cost: number
169
244
  cacheHitRate: number | null
245
+ todayCost: number
170
246
  }
171
247
 
172
248
  /** The usage fields this footer totals, read structurally so no cast is needed. */
@@ -178,7 +254,7 @@ type UsageTotals = {
178
254
  cost: { total: number }
179
255
  }
180
256
 
181
- function collectTotals(ctx: ExtensionContext): Totals {
257
+ function collectTotals(ctx: ExtensionContext, since: number): Totals {
182
258
  const totals: Totals = {
183
259
  input: 0,
184
260
  output: 0,
@@ -186,29 +262,38 @@ function collectTotals(ctx: ExtensionContext): Totals {
186
262
  cacheWrite: 0,
187
263
  cost: 0,
188
264
  cacheHitRate: null,
265
+ todayCost: 0,
189
266
  }
190
267
  // `usage` stays optional here even though an assistant message always carries it:
191
268
  // a truncated or hand-edited session file is the case this guard is for.
192
- const add = (usage: UsageTotals | undefined, assistant: boolean): void => {
269
+ const add = (usage: UsageTotals | undefined, assistant: boolean, isToday: boolean): void => {
193
270
  if (!usage) return
194
271
  totals.input += usage.input
195
272
  totals.output += usage.output
196
273
  totals.cacheRead += usage.cacheRead
197
274
  totals.cacheWrite += usage.cacheWrite
198
275
  totals.cost += usage.cost.total
276
+ if (isToday) totals.todayCost += usage.cost.total
199
277
  if (!assistant) return
200
278
  const prompt = usage.input + usage.cacheRead + usage.cacheWrite
201
279
  if (prompt > 0) totals.cacheHitRate = (usage.cacheRead / prompt) * 100
202
280
  }
203
281
  for (const entry of ctx.sessionManager.getEntries()) {
282
+ const stamp =
283
+ 'timestamp' in entry && typeof entry.timestamp === 'string'
284
+ ? Date.parse(entry.timestamp)
285
+ : Number.NaN
286
+ const isToday = Number.isFinite(stamp) && stamp >= since
204
287
  if (entry.type === 'message') {
205
288
  const { message } = entry
206
- if (message.role === 'assistant') add(message.usage, true)
207
- else if (message.role === 'toolResult') add(message.usage, false)
289
+ if (message.role === 'assistant') add(message.usage, true, isToday)
290
+ else if (message.role === 'toolResult') add(message.usage, false, isToday)
208
291
  continue
209
292
  }
210
293
  // Compaction and branch summaries bill a model call of their own.
211
- if (entry.type === 'compaction' || entry.type === 'branch_summary') add(entry.usage, false)
294
+ if (entry.type === 'compaction' || entry.type === 'branch_summary') {
295
+ add(entry.usage, false, isToday)
296
+ }
212
297
  }
213
298
  return totals
214
299
  }
@@ -240,6 +325,8 @@ export default function (pi: ExtensionAPI) {
240
325
  let requestAt: number | null = null
241
326
  let firstTokenAt: number | null = null
242
327
  let totalDecodeMs = 0
328
+ let totalMeasuredOutput = 0
329
+ let todayBase = 0
243
330
  let ticker: ReturnType<typeof setInterval> | null = null
244
331
  let windowAt = 0
245
332
  let windowTokens = 0
@@ -289,8 +376,8 @@ export default function (pi: ExtensionAPI) {
289
376
  },
290
377
  render(width: number): string[] {
291
378
  const usage = ctx.getContextUsage()
292
- const totals = collectTotals(ctx)
293
- const avg = avgTokPerSec(totals.output, totalDecodeMs)
379
+ const totals = collectTotals(ctx, startOfToday())
380
+ const avg = avgTokPerSec(totalMeasuredOutput, totalDecodeMs)
294
381
  const row1 = contextRow(
295
382
  theme,
296
383
  width,
@@ -302,6 +389,7 @@ export default function (pi: ExtensionAPI) {
302
389
  output: totals.output,
303
390
  cacheHitRate: totals.cacheHitRate,
304
391
  cost: totals.cost,
392
+ todayCost: todayBase + totals.todayCost,
305
393
  },
306
394
  currency,
307
395
  )
@@ -388,9 +476,12 @@ export default function (pi: ExtensionAPI) {
388
476
  reading = null
389
477
  requestAt = null
390
478
  totalDecodeMs = 0
479
+ totalMeasuredOutput = 0
391
480
  stopTicker()
392
481
  resetStream()
393
482
  currency = await loadCurrency((message) => ctx.ui.notify(message, 'warning'))
483
+ const file = ctx.sessionManager.getSessionFile()
484
+ todayBase = await sumOtherTodaysCost(file ?? null, startOfToday())
394
485
  installFooter(ctx)
395
486
  })
396
487
 
@@ -470,8 +561,11 @@ export default function (pi: ExtensionAPI) {
470
561
  const measured = ttftMs(requestAt, firstTokenAt)
471
562
  const tokens = output > 0 ? output : totalChars * (ratio ?? FALLBACK_TOKENS_PER_CHAR)
472
563
  if (measured !== null && decodeMs >= MIN_SAMPLE_MS) {
473
- // Session-average numerator lives in collectTotals; this is its denominator.
564
+ // The average's numerator and denominator must cover the same messages: totals.output spans
565
+ // the whole session (a reload replays none of it), so pairing it with the partial denominator
566
+ // below once produced an Avg of 4324 tok/s.
474
567
  totalDecodeMs += decodeMs
568
+ if (output > 0) totalMeasuredOutput += output
475
569
  publish((tokens / decodeMs) * 1000, output > 0, measured)
476
570
  }
477
571
  // Null it with the stream: a request that has produced its message is no longer in flight, and
@@ -397,6 +397,8 @@ export interface ContextRowParts {
397
397
  output: number
398
398
  cacheHitRate: number | null
399
399
  cost: number
400
+ /** Today's spend across every session on this machine, or 0 to hide the slot. */
401
+ todayCost: number
400
402
  }
401
403
 
402
404
  /**
@@ -421,7 +423,8 @@ export function contextRow(
421
423
  const fraction = parts.percent === null ? 0 : parts.percent / 100
422
424
  const percent = theme.fg(
423
425
  percentColor(parts.percent),
424
- parts.percent === null ? '?' : `${parts.percent.toFixed(1)}%`,
426
+ // Whole percents: the meter carries the precision, and a decimal here is noise.
427
+ parts.percent === null ? '?' : `${Math.round(parts.percent)}%`,
425
428
  )
426
429
  const meter = `${theme.fg('dim', 'Context')} ${bar(theme, BAR_CELLS, fraction)} ${percent}`
427
430
  const detail =
@@ -437,6 +440,9 @@ export function contextRow(
437
440
  outcomes.push(pair(theme, 'Cache hit', `${parts.cacheHitRate.toFixed(1)}%`))
438
441
  }
439
442
  if (parts.cost > 0) outcomes.push(pair(theme, 'Cost', formatCost(parts.cost, currency)))
443
+ if (parts.todayCost > 0) {
444
+ outcomes.push(pair(theme, 'Today', formatCost(parts.todayCost, currency)))
445
+ }
440
446
 
441
447
  const separator = theme.fg('dim', ' · ')
442
448
  const full = [...volumes, ...outcomes].join(separator)