@tduyng/pi-cost 0.0.0-stage → 0.0.1

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,161 @@
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
+ void refreshGit()
61
+ })
62
+
63
+ pi.registerCommand('statusline', {
64
+ description: 'Toggle the extra statusline segments',
65
+ handler: async (_args, ctx) => {
66
+ enabled = !enabled
67
+ if (!ctx.hasUI) return
68
+ if (enabled) mount(ctx)
69
+ else unmount()
70
+ ctx.ui.notify(`statusline ${enabled ? 'enabled' : 'disabled'}`, 'info')
71
+ }
72
+ })
73
+
74
+ async function refreshGit(): Promise<void> {
75
+ const ctx = mountedCtx
76
+ if (!ctx) return
77
+ try {
78
+ const status = await pi.exec('git', ['status', '--porcelain'], { cwd: ctx.cwd })
79
+ changedFiles = status.stdout.split('\n').filter((line) => line.trim().length > 0).length
80
+ } catch {
81
+ changedFiles = undefined
82
+ }
83
+ try {
84
+ sync()
85
+ } catch {}
86
+ }
87
+
88
+ async function refreshPr(): Promise<void> {
89
+ const ctx = mountedCtx
90
+ if (!ctx || !showPr) return
91
+ try {
92
+ const pr = await pi.exec('gh', ['pr', 'view', '--json', 'number', '-q', '.number'], { cwd: ctx.cwd })
93
+ const parsed = Number.parseInt(pr.stdout.trim(), 10)
94
+ prNumber = Number.isNaN(parsed) ? undefined : parsed
95
+ } catch {
96
+ prNumber = undefined
97
+ }
98
+ try {
99
+ sync()
100
+ } catch {}
101
+ }
102
+
103
+ function sync(): void {
104
+ const ctx = mountedCtx
105
+ if (!ctx?.hasUI || !enabled) return
106
+ const theme = ctx.ui.theme
107
+
108
+ const tps = averageTps(speedSamples)
109
+ ctx.ui.setStatus('tps', tps !== undefined && tps > 0 ? theme.fg('dim', `${tps} tok/s`) : undefined)
110
+
111
+ ctx.ui.setStatus(
112
+ 'agents',
113
+ runningAgents > 0
114
+ ? theme.fg('accent', `${runningAgents} agent${runningAgents === 1 ? '' : 's'}`)
115
+ : undefined
116
+ )
117
+
118
+ const gitParts: string[] = []
119
+ if (changedFiles !== undefined && changedFiles > 0) gitParts.push(`${changedFiles}±`)
120
+ if (prNumber !== undefined) gitParts.push(`#${prNumber}`)
121
+ ctx.ui.setStatus('git', gitParts.length > 0 ? theme.fg('success', gitParts.join(' · ')) : undefined)
122
+ }
123
+
124
+ function mount(ctx: ExtensionContext): void {
125
+ if (!ctx.hasUI) return
126
+ mountedCtx = ctx
127
+
128
+ void refreshGit()
129
+ void refreshPr()
130
+ watchGitHead()
131
+ sync()
132
+ }
133
+
134
+ function watchGitHead(): void {
135
+ const ctx = mountedCtx
136
+ gitHeadWatcher?.close()
137
+ gitHeadWatcher = undefined
138
+ if (!ctx?.cwd) return
139
+ try {
140
+ gitHeadWatcher = fs.watch(path.join(ctx.cwd, '.git', 'HEAD'), { persistent: false }, () => {
141
+ if (gitWatchDebounce) clearTimeout(gitWatchDebounce)
142
+ gitWatchDebounce = setTimeout(() => {
143
+ void refreshGit()
144
+ void refreshPr()
145
+ }, 250)
146
+ })
147
+ } catch {}
148
+ }
149
+
150
+ function unmount(): void {
151
+ gitHeadWatcher?.close()
152
+ gitHeadWatcher = undefined
153
+ if (gitWatchDebounce) clearTimeout(gitWatchDebounce)
154
+ gitWatchDebounce = undefined
155
+ const ctx = mountedCtx
156
+ if (ctx?.hasUI) {
157
+ for (const key of ['tps', 'agents', 'git']) ctx.ui.setStatus(key, undefined)
158
+ }
159
+ mountedCtx = undefined
160
+ }
161
+ }
@@ -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,79 @@
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
+ const DUPLICATE_WINDOW_MS = 2_000
41
+
42
+ export function hasDuplicateTimestampEntry(
43
+ entries: readonly unknown[],
44
+ role: 'user' | 'assistant',
45
+ timestamp: number
46
+ ): boolean {
47
+ const lookBack = 10
48
+ for (let i = entries.length - 1; i >= 0 && i >= entries.length - lookBack; i--) {
49
+ const entry = entries[i] as { type?: string; customType?: string; data?: TimestampEntryData } | undefined
50
+ if (!entry || entry.type !== 'custom' || entry.customType !== ENTRY_TYPE) continue
51
+ if (!entry.data || entry.data.role !== role) continue
52
+ if (Math.abs((entry.data.timestamp ?? Number.NaN) - timestamp) <= DUPLICATE_WINDOW_MS) return true
53
+ }
54
+ return false
55
+ }
56
+
57
+ export const ENTRY_TYPE = 'timestamps'
58
+
59
+ /** The entries the duplicate check reads, walked back by parent id instead of copied whole. */
60
+ export interface SessionWalk {
61
+ getLeafEntry?: () => SessionEntry | undefined
62
+ getEntry?: (id: string) => SessionEntry | undefined
63
+ getEntries?: () => SessionEntry[]
64
+ }
65
+
66
+ export function recentEntries(session: SessionWalk, limit = 10): SessionEntry[] {
67
+ const leaf = session.getLeafEntry?.()
68
+ if (leaf !== undefined && session.getEntry !== undefined) {
69
+ const out: SessionEntry[] = []
70
+ let entry: SessionEntry | undefined = leaf
71
+ while (entry !== undefined && out.length < limit) {
72
+ out.push(entry)
73
+ if (out.length === limit) break
74
+ entry = entry.parentId === null ? undefined : session.getEntry(entry.parentId)
75
+ }
76
+ return out
77
+ }
78
+ return (session.getEntries?.() ?? []).slice(-limit).toReversed()
79
+ }