@tduyng/pi-cost 0.0.0-stage → 0.0.2

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.
@@ -0,0 +1,162 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
4
+
5
+ import { averageTps, type SpeedSample } from './logic.js'
6
+
7
+ export default function statuslineExtension(pi: ExtensionAPI): void {
8
+ pi.registerFlag('statusline-no-pr', { description: 'Skip the GitHub PR lookup in the statusline', type: 'boolean' })
9
+
10
+ const showPr = pi.getFlag('statusline-no-pr') !== true
11
+
12
+ let enabled = true
13
+ const speedSamples: SpeedSample[] = []
14
+ let responseStartedAt: number | undefined
15
+
16
+ let changedFiles: number | undefined
17
+ let prNumber: number | undefined
18
+ let runningAgents = 0
19
+
20
+ let gitHeadWatcher: fs.FSWatcher | undefined
21
+ let gitWatchDebounce: ReturnType<typeof setTimeout> | undefined
22
+ let mountedCtx: ExtensionContext | undefined
23
+
24
+ pi.on('session_start', async (_event, ctx) => {
25
+ if (!ctx.hasUI) return
26
+ runningAgents = 0
27
+ speedSamples.length = 0
28
+ mount(ctx)
29
+ })
30
+
31
+ pi.on('session_shutdown', async () => {
32
+ unmount()
33
+ })
34
+
35
+ pi.on('message_start', (event) => {
36
+ if (event.message.role === 'assistant') responseStartedAt = Date.now()
37
+ })
38
+
39
+ pi.on('message_end', async (event) => {
40
+ const message = event.message
41
+ if (message.role !== 'assistant') return
42
+ const usage = message.usage
43
+ if (!usage) return
44
+ const ms = responseStartedAt !== undefined ? Date.now() - responseStartedAt : 0
45
+ if (ms > 0) speedSamples.push({ outputTokens: usage.output, ms })
46
+ responseStartedAt = undefined
47
+ sync()
48
+ })
49
+
50
+ pi.events.on('subagent:started', () => {
51
+ runningAgents += 1
52
+ sync()
53
+ })
54
+ pi.events.on('subagent:completed', () => {
55
+ runningAgents = Math.max(0, runningAgents - 1)
56
+ sync()
57
+ })
58
+
59
+ pi.on('agent_settled', () => {
60
+ if (!enabled) return
61
+ void refreshGit()
62
+ })
63
+
64
+ pi.registerCommand('statusline', {
65
+ description: 'Toggle the extra statusline segments',
66
+ handler: async (_args, ctx) => {
67
+ enabled = !enabled
68
+ if (!ctx.hasUI) return
69
+ if (enabled) mount(ctx)
70
+ else unmount()
71
+ ctx.ui.notify(`statusline ${enabled ? 'enabled' : 'disabled'}`, 'info')
72
+ }
73
+ })
74
+
75
+ async function refreshGit(): Promise<void> {
76
+ const ctx = mountedCtx
77
+ if (!ctx || !enabled) return
78
+ try {
79
+ const status = await pi.exec('git', ['status', '--porcelain'], { cwd: ctx.cwd })
80
+ changedFiles = status.stdout.split('\n').filter((line) => line.trim().length > 0).length
81
+ } catch {
82
+ changedFiles = undefined
83
+ }
84
+ try {
85
+ sync()
86
+ } catch {}
87
+ }
88
+
89
+ async function refreshPr(): Promise<void> {
90
+ const ctx = mountedCtx
91
+ if (!ctx || !showPr || !enabled) return
92
+ try {
93
+ const pr = await pi.exec('gh', ['pr', 'view', '--json', 'number', '-q', '.number'], { cwd: ctx.cwd })
94
+ const parsed = Number.parseInt(pr.stdout.trim(), 10)
95
+ prNumber = Number.isNaN(parsed) ? undefined : parsed
96
+ } catch {
97
+ prNumber = undefined
98
+ }
99
+ try {
100
+ sync()
101
+ } catch {}
102
+ }
103
+
104
+ function sync(): void {
105
+ const ctx = mountedCtx
106
+ if (!ctx?.hasUI || !enabled) return
107
+ const theme = ctx.ui.theme
108
+
109
+ const tps = averageTps(speedSamples)
110
+ ctx.ui.setStatus('tps', tps !== undefined && tps > 0 ? theme.fg('dim', `${tps} tok/s`) : undefined)
111
+
112
+ ctx.ui.setStatus(
113
+ 'agents',
114
+ runningAgents > 0
115
+ ? theme.fg('accent', `${runningAgents} agent${runningAgents === 1 ? '' : 's'}`)
116
+ : undefined
117
+ )
118
+
119
+ const gitParts: string[] = []
120
+ if (changedFiles !== undefined && changedFiles > 0) gitParts.push(`${changedFiles}±`)
121
+ if (prNumber !== undefined) gitParts.push(`#${prNumber}`)
122
+ ctx.ui.setStatus('git', gitParts.length > 0 ? theme.fg('success', gitParts.join(' · ')) : undefined)
123
+ }
124
+
125
+ function mount(ctx: ExtensionContext): void {
126
+ if (!ctx.hasUI || !enabled) return
127
+ mountedCtx = ctx
128
+
129
+ void refreshGit()
130
+ void refreshPr()
131
+ watchGitHead()
132
+ sync()
133
+ }
134
+
135
+ function watchGitHead(): void {
136
+ const ctx = mountedCtx
137
+ gitHeadWatcher?.close()
138
+ gitHeadWatcher = undefined
139
+ if (!ctx?.cwd) return
140
+ try {
141
+ gitHeadWatcher = fs.watch(path.join(ctx.cwd, '.git', 'HEAD'), { persistent: false }, () => {
142
+ if (gitWatchDebounce) clearTimeout(gitWatchDebounce)
143
+ gitWatchDebounce = setTimeout(() => {
144
+ void refreshGit()
145
+ void refreshPr()
146
+ }, 250)
147
+ })
148
+ } catch {}
149
+ }
150
+
151
+ function unmount(): void {
152
+ gitHeadWatcher?.close()
153
+ gitHeadWatcher = undefined
154
+ if (gitWatchDebounce) clearTimeout(gitWatchDebounce)
155
+ gitWatchDebounce = undefined
156
+ const ctx = mountedCtx
157
+ if (ctx?.hasUI) {
158
+ for (const key of ['tps', 'agents', 'git']) ctx.ui.setStatus(key, undefined)
159
+ }
160
+ mountedCtx = undefined
161
+ }
162
+ }
@@ -0,0 +1,68 @@
1
+ export interface UsageCost {
2
+ input: number
3
+ output: number
4
+ cacheRead: number
5
+ cacheWrite: number
6
+ total: number
7
+ }
8
+
9
+ export interface TokenUsage {
10
+ input: number
11
+ output: number
12
+ cacheRead: number
13
+ cacheWrite: number
14
+ cost?: UsageCost
15
+ }
16
+
17
+ export interface ModelRates {
18
+ input: number
19
+ output: number
20
+ cacheRead: number
21
+ cacheWrite: number
22
+ }
23
+
24
+ function hasPricing(rates: ModelRates | undefined): boolean {
25
+ if (!rates) return false
26
+ return rates.input > 0 || rates.output > 0 || rates.cacheRead > 0 || rates.cacheWrite > 0
27
+ }
28
+
29
+ export function turnCostUsd(usage: TokenUsage, rates?: ModelRates): number {
30
+ if (usage.cost && usage.cost.total > 0) return usage.cost.total
31
+ const r = rates
32
+ if (!r || !hasPricing(r)) return 0
33
+ return (
34
+ (r.input / 1_000_000) * usage.input +
35
+ (r.output / 1_000_000) * usage.output +
36
+ (r.cacheRead / 1_000_000) * usage.cacheRead +
37
+ (r.cacheWrite / 1_000_000) * usage.cacheWrite
38
+ )
39
+ }
40
+
41
+ export function addTurnCost(totalUsd: number, usage: TokenUsage | undefined, rates?: ModelRates): number {
42
+ if (!usage) return totalUsd
43
+ return totalUsd + Math.max(0, turnCostUsd(usage, rates))
44
+ }
45
+
46
+ export function formatCostUsd(amount: number): string {
47
+ if (!Number.isFinite(amount) || amount <= 0) return '$0.00'
48
+ if (amount < 0.01) return `$${amount.toFixed(4)}`
49
+ if (amount < 1) return `$${amount.toFixed(3)}`
50
+ return `$${amount.toFixed(2)}`
51
+ }
52
+
53
+ export interface SpeedSample {
54
+ outputTokens: number
55
+ ms: number
56
+ }
57
+
58
+ export function averageTps(samples: SpeedSample[]): number | undefined {
59
+ let tokens = 0
60
+ let seconds = 0
61
+ for (const s of samples) {
62
+ if (s.ms <= 0) continue
63
+ tokens += s.outputTokens
64
+ seconds += s.ms / 1000
65
+ }
66
+ if (seconds <= 0) return undefined
67
+ return Math.round(tokens / seconds)
68
+ }
@@ -0,0 +1,43 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
2
+ import { Text } from '@earendil-works/pi-tui'
3
+
4
+ import {
5
+ ENTRY_TYPE,
6
+ formatTimestampLine,
7
+ hasDuplicateTimestampEntry,
8
+ isToolCallMessage,
9
+ recentEntries,
10
+ type TimestampEntryData,
11
+ type TimestampKind
12
+ } from './logic.js'
13
+
14
+ export default function timestampsExtension(pi: ExtensionAPI): void {
15
+ let enabled = true
16
+
17
+ pi.on('message_end', async (event, ctx) => {
18
+ const message = event.message
19
+
20
+ if (enabled && (message.role === 'user' || message.role === 'assistant')) {
21
+ const timestamp = message.timestamp ?? Date.now()
22
+ const kind: TimestampKind =
23
+ message.role === 'user' ? 'user' : isToolCallMessage(message) ? 'tool' : 'assistant'
24
+ if (!hasDuplicateTimestampEntry(recentEntries(ctx.sessionManager), message.role, timestamp)) {
25
+ pi.appendEntry(ENTRY_TYPE, { role: message.role, kind, timestamp } satisfies TimestampEntryData)
26
+ }
27
+ }
28
+ })
29
+
30
+ pi.registerEntryRenderer(ENTRY_TYPE, (entry, _options, theme) => {
31
+ if (!enabled || !entry.data) return undefined
32
+ const data = entry.data as TimestampEntryData
33
+ return new Text(theme.fg('dim', formatTimestampLine(data, Date.now())), 0, 0)
34
+ })
35
+
36
+ pi.registerCommand('timestamps', {
37
+ description: 'Toggle message timestamps',
38
+ handler: async (_args, ctx) => {
39
+ enabled = !enabled
40
+ if (ctx.hasUI) ctx.ui.notify(`timestamps ${enabled ? 'enabled' : 'disabled'}`, 'info')
41
+ }
42
+ })
43
+ }
@@ -0,0 +1,77 @@
1
+ import type { SessionEntry } from '@earendil-works/pi-coding-agent'
2
+
3
+ export type TimestampKind = 'user' | 'assistant' | 'tool'
4
+ export type TimestampEntryData = {
5
+ role: 'user' | 'assistant'
6
+ kind?: TimestampKind
7
+ timestamp: number
8
+ }
9
+
10
+ export function formatClock(ts: number): string {
11
+ const d = new Date(ts)
12
+ const pad = (n: number) => String(n).padStart(2, '0')
13
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
14
+ }
15
+
16
+ export function formatRelative(ts: number, now: number): string {
17
+ const diff = Math.max(0, Math.floor((now - ts) / 1000))
18
+ if (diff < 5) return 'now'
19
+ if (diff < 60) return `${diff}s ago`
20
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
21
+ return `${Math.floor(diff / 3600)}h ago`
22
+ }
23
+
24
+ export function describeKind(kind: TimestampKind | undefined, role: TimestampEntryData['role']): string {
25
+ const resolved = kind ?? (role === 'user' ? 'user' : 'assistant')
26
+ return resolved === 'tool' ? 'tool call' : resolved === 'user' ? 'user message' : 'ai response'
27
+ }
28
+
29
+ export function formatTimestampLine(data: TimestampEntryData, now: number): string {
30
+ return `${formatClock(data.timestamp)} (${formatRelative(data.timestamp, now)}) · ${describeKind(data.kind, data.role)}`
31
+ }
32
+
33
+ export function isToolCallMessage(message: { stopReason?: string; content?: { type?: string }[] }): boolean {
34
+ return (
35
+ message.stopReason === 'toolUse' ||
36
+ (Array.isArray(message.content) && message.content.some((block) => block?.type === 'toolCall'))
37
+ )
38
+ }
39
+
40
+ export function hasDuplicateTimestampEntry(
41
+ entries: readonly unknown[],
42
+ role: 'user' | 'assistant',
43
+ timestamp: number
44
+ ): boolean {
45
+ const lookBack = 10
46
+ for (let i = entries.length - 1; i >= 0 && i >= entries.length - lookBack; i--) {
47
+ const entry = entries[i] as { type?: string; customType?: string; data?: TimestampEntryData } | undefined
48
+ if (!entry || entry.type !== 'custom' || entry.customType !== ENTRY_TYPE) continue
49
+ if (!entry.data || entry.data.role !== role) continue
50
+ if (entry.data.timestamp === timestamp) return true
51
+ }
52
+ return false
53
+ }
54
+
55
+ export const ENTRY_TYPE = 'timestamps'
56
+
57
+ /** The entries the duplicate check reads, walked back by parent id instead of copied whole. */
58
+ export interface SessionWalk {
59
+ getLeafEntry?: () => SessionEntry | undefined
60
+ getEntry?: (id: string) => SessionEntry | undefined
61
+ getEntries?: () => SessionEntry[]
62
+ }
63
+
64
+ export function recentEntries(session: SessionWalk, limit = 10): SessionEntry[] {
65
+ const leaf = session.getLeafEntry?.()
66
+ if (leaf !== undefined && session.getEntry !== undefined) {
67
+ const out: SessionEntry[] = []
68
+ let entry: SessionEntry | undefined = leaf
69
+ while (entry !== undefined && out.length < limit) {
70
+ out.push(entry)
71
+ if (out.length === limit) break
72
+ entry = entry.parentId === null ? undefined : session.getEntry(entry.parentId)
73
+ }
74
+ return out
75
+ }
76
+ return (session.getEntries?.() ?? []).slice(-limit).toReversed()
77
+ }