@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.
- package/LICENSE +178 -0
- package/README.md +69 -2
- package/package.json +47 -4
- package/src/cost/config.ts +46 -0
- package/src/cost/index.ts +460 -0
- package/src/cost/logic.ts +513 -0
- package/src/cost/overhead.ts +70 -0
- package/src/statusline/index.ts +162 -0
- package/src/statusline/logic.ts +68 -0
- package/src/timestamps/index.ts +43 -0
- package/src/timestamps/logic.ts +77 -0
|
@@ -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
|
+
}
|