@doguyilmaz/konvoy 0.1.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.
- package/LICENSE +21 -0
- package/README.md +300 -0
- package/package.json +52 -0
- package/src/adapters/claude.ts +83 -0
- package/src/adapters/codex.ts +67 -0
- package/src/adapters/effort.ts +16 -0
- package/src/adapters/index.ts +20 -0
- package/src/adapters/kiro.ts +79 -0
- package/src/adapters/opencode.ts +64 -0
- package/src/adapters/types.ts +108 -0
- package/src/args.ts +42 -0
- package/src/chart.ts +91 -0
- package/src/cli.ts +146 -0
- package/src/commands/attach.ts +85 -0
- package/src/commands/config.ts +113 -0
- package/src/commands/dashboard.ts +26 -0
- package/src/commands/doctor.ts +104 -0
- package/src/commands/ls.ts +15 -0
- package/src/commands/new.ts +24 -0
- package/src/commands/resume.ts +14 -0
- package/src/commands/rm.ts +28 -0
- package/src/commands/roster.ts +37 -0
- package/src/commands/send.ts +79 -0
- package/src/commands/status.ts +35 -0
- package/src/commands/table.ts +75 -0
- package/src/commands/update.ts +72 -0
- package/src/commands/usage.ts +77 -0
- package/src/config/load.ts +335 -0
- package/src/config/schema.ts +100 -0
- package/src/core/children.ts +62 -0
- package/src/core/detect.ts +211 -0
- package/src/core/facts.ts +113 -0
- package/src/core/gate.ts +73 -0
- package/src/core/prelude.ts +121 -0
- package/src/core/session.ts +334 -0
- package/src/core/turn.ts +263 -0
- package/src/dashboard/page.ts +211 -0
- package/src/format.ts +98 -0
- package/src/paths.ts +33 -0
- package/src/pricing.ts +86 -0
- package/src/store/db.ts +78 -0
- package/src/store/queries.ts +434 -0
- package/src/types.ts +71 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import {
|
|
3
|
+
turnsPerDay,
|
|
4
|
+
turnsPerDayByAgent,
|
|
5
|
+
usageAcrossSessions,
|
|
6
|
+
usageByAgentModel,
|
|
7
|
+
usageForSession,
|
|
8
|
+
} from '../store/queries'
|
|
9
|
+
import type { Config } from '../config/schema'
|
|
10
|
+
import { estimateAgentUsd } from '../pricing'
|
|
11
|
+
import { spend } from '../format'
|
|
12
|
+
|
|
13
|
+
export interface DashboardData {
|
|
14
|
+
title: string
|
|
15
|
+
asOf: string
|
|
16
|
+
agents: {
|
|
17
|
+
agent: string
|
|
18
|
+
turns: number
|
|
19
|
+
inputTokens: number
|
|
20
|
+
outputTokens: number
|
|
21
|
+
spend: string
|
|
22
|
+
estimateUsd: number | null
|
|
23
|
+
}[]
|
|
24
|
+
days: { day: string; count: number }[]
|
|
25
|
+
byAgentDay: { agent: string; day: string; count: number }[]
|
|
26
|
+
totals: { turns: number; inputTokens: number; outputTokens: number }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// collect is a pure read of the same tables `konvoy usage --chart` reads, through the same
|
|
30
|
+
// queries — a parallel query here is exactly how the two views would start disagreeing
|
|
31
|
+
export function collect(db: Database, cfg: Config, sessionId?: string): DashboardData {
|
|
32
|
+
const rows = sessionId ? usageForSession(db, sessionId) : usageAcrossSessions(db)
|
|
33
|
+
const modelRows = usageByAgentModel(db, sessionId)
|
|
34
|
+
|
|
35
|
+
const agents = rows.map((r) => ({
|
|
36
|
+
agent: r.agent,
|
|
37
|
+
turns: r.turns,
|
|
38
|
+
inputTokens: r.inputTokens,
|
|
39
|
+
outputTokens: r.outputTokens,
|
|
40
|
+
spend: spend(r),
|
|
41
|
+
estimateUsd: estimateAgentUsd(r.agent, modelRows, cfg.pricing),
|
|
42
|
+
}))
|
|
43
|
+
|
|
44
|
+
const totals = rows.reduce(
|
|
45
|
+
(acc, r) => ({
|
|
46
|
+
turns: acc.turns + r.turns,
|
|
47
|
+
inputTokens: acc.inputTokens + r.inputTokens,
|
|
48
|
+
outputTokens: acc.outputTokens + r.outputTokens,
|
|
49
|
+
}),
|
|
50
|
+
{ turns: 0, inputTokens: 0, outputTokens: 0 },
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
title: '',
|
|
55
|
+
asOf: cfg.pricing.asOf,
|
|
56
|
+
agents,
|
|
57
|
+
days: turnsPerDay(db, sessionId),
|
|
58
|
+
byAgentDay: turnsPerDayByAgent(db, sessionId),
|
|
59
|
+
totals,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function escape(text: string): string {
|
|
64
|
+
return text.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// every day between the first and last, inclusive — gaps filled with zero downstream — so
|
|
68
|
+
// bars line up between agents the same way the terminal's sparklines line up columns
|
|
69
|
+
function expandDays(start: string, end: string): string[] {
|
|
70
|
+
const days: string[] = []
|
|
71
|
+
for (
|
|
72
|
+
const cursor = new Date(`${start}T00:00:00Z`);
|
|
73
|
+
cursor <= new Date(`${end}T00:00:00Z`);
|
|
74
|
+
cursor.setUTCDate(cursor.getUTCDate() + 1)
|
|
75
|
+
) {
|
|
76
|
+
days.push(cursor.toISOString().slice(0, 10))
|
|
77
|
+
}
|
|
78
|
+
return days
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function usdCell(a: DashboardData['agents'][number]): string {
|
|
82
|
+
if (a.estimateUsd === null) return '-'
|
|
83
|
+
return a.spend.startsWith('$') ? `$${a.estimateUsd.toFixed(2)}` : `$${a.estimateUsd.toFixed(3)}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function totalsBarsSvg(days: { day: string; count: number }[]): string {
|
|
87
|
+
if (days.length === 0) return '<p class="empty">no turns recorded yet</p>'
|
|
88
|
+
const range = expandDays(days[0]!.day, days[days.length - 1]!.day)
|
|
89
|
+
const counts = new Map(days.map((d) => [d.day, d.count]))
|
|
90
|
+
const values = range.map((d) => counts.get(d) ?? 0)
|
|
91
|
+
const max = Math.max(...values, 1)
|
|
92
|
+
|
|
93
|
+
const barW = 14
|
|
94
|
+
const gap = 4
|
|
95
|
+
const height = 84
|
|
96
|
+
const floor = height - 14
|
|
97
|
+
const width = range.length * (barW + gap)
|
|
98
|
+
|
|
99
|
+
const bars = values
|
|
100
|
+
.map((v, i) => {
|
|
101
|
+
const h = v > 0 ? Math.max(1, Math.round((v / max) * (floor - 4))) : 0
|
|
102
|
+
const x = i * (barW + gap)
|
|
103
|
+
const y = floor - h
|
|
104
|
+
return `<rect x="${x}" y="${y}" width="${barW}" height="${h}" rx="2"></rect><text x="${x + barW / 2}" y="${height - 2}" text-anchor="middle">${escape(range[i]!.slice(5))}</text>`
|
|
105
|
+
})
|
|
106
|
+
.join('')
|
|
107
|
+
|
|
108
|
+
return `<svg viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-label="turns per day">${bars}</svg>`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function agentBarsSvg(rows: { agent: string; day: string; count: number }[]): string {
|
|
112
|
+
if (rows.length === 0) return '<p class="empty">no turns recorded yet</p>'
|
|
113
|
+
|
|
114
|
+
const sortedDays = [...new Set(rows.map((r) => r.day))].sort()
|
|
115
|
+
const range = expandDays(sortedDays[0]!, sortedDays[sortedDays.length - 1]!)
|
|
116
|
+
|
|
117
|
+
const byAgent = new Map<string, Map<string, number>>()
|
|
118
|
+
for (const r of rows) {
|
|
119
|
+
if (!byAgent.has(r.agent)) byAgent.set(r.agent, new Map())
|
|
120
|
+
byAgent.get(r.agent)!.set(r.day, r.count)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const series = [...byAgent.entries()]
|
|
124
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
125
|
+
.map(([agent, dayCounts]) => ({ agent, values: range.map((d) => dayCounts.get(d) ?? 0) }))
|
|
126
|
+
|
|
127
|
+
// one shared max across every agent — a per-row scale would draw a quiet agent's two
|
|
128
|
+
// turns at a busy agent's height, hiding exactly the comparison this chart is for
|
|
129
|
+
const sharedMax = Math.max(...series.flatMap((s) => s.values), 1)
|
|
130
|
+
|
|
131
|
+
const barW = 10
|
|
132
|
+
const gap = 3
|
|
133
|
+
const rowHeight = 34
|
|
134
|
+
const width = range.length * (barW + gap)
|
|
135
|
+
|
|
136
|
+
const rowsHtml = series
|
|
137
|
+
.map(({ agent, values }) => {
|
|
138
|
+
const bars = values
|
|
139
|
+
.map((v, i) => {
|
|
140
|
+
const h = v > 0 ? Math.max(1, Math.round((v / sharedMax) * (rowHeight - 2))) : 0
|
|
141
|
+
const x = i * (barW + gap)
|
|
142
|
+
const y = rowHeight - h
|
|
143
|
+
return `<rect x="${x}" y="${y}" width="${barW}" height="${h}"></rect>`
|
|
144
|
+
})
|
|
145
|
+
.join('')
|
|
146
|
+
return `<div class="agent-row" data-agent="${escape(agent)}"><span class="agent-label">${escape(agent)}</span><svg viewBox="0 0 ${width} ${rowHeight}" width="${width}" height="${rowHeight}" role="img" aria-label="turns per day for ${escape(agent)}">${bars}</svg></div>`
|
|
147
|
+
})
|
|
148
|
+
.join('')
|
|
149
|
+
|
|
150
|
+
return `<div class="agent-bars">${rowsHtml}</div>`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const STYLE = `
|
|
154
|
+
:root { color-scheme: light dark; }
|
|
155
|
+
body { font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 2rem; max-width: 860px; }
|
|
156
|
+
h1 { font-size: 1.25rem; margin-bottom: 0.25rem; }
|
|
157
|
+
.totals { color: #666; margin-top: 0; }
|
|
158
|
+
table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
|
|
159
|
+
th, td { text-align: left; padding: 0.35rem 0.75rem 0.35rem 0; border-bottom: 1px solid #8883; }
|
|
160
|
+
h2 { font-size: 1rem; margin-top: 2rem; }
|
|
161
|
+
svg rect { fill: currentColor; opacity: 0.75; }
|
|
162
|
+
svg text { font-size: 8px; fill: currentColor; opacity: 0.6; }
|
|
163
|
+
.agent-row { display: flex; align-items: center; gap: 0.75rem; margin: 0.4rem 0; }
|
|
164
|
+
.agent-label { width: 5rem; flex: none; }
|
|
165
|
+
.empty, .note { color: #888; }
|
|
166
|
+
`
|
|
167
|
+
|
|
168
|
+
export function renderPage(data: DashboardData): string {
|
|
169
|
+
const title = escape(data.title)
|
|
170
|
+
|
|
171
|
+
const agentRows = data.agents
|
|
172
|
+
.map(
|
|
173
|
+
(a) => `<tr>
|
|
174
|
+
<td>${escape(a.agent)}</td>
|
|
175
|
+
<td>${a.turns}</td>
|
|
176
|
+
<td>${a.inputTokens}</td>
|
|
177
|
+
<td>${a.outputTokens}</td>
|
|
178
|
+
<td>${escape(a.spend)}</td>
|
|
179
|
+
<td>${usdCell(a)}</td>
|
|
180
|
+
</tr>`,
|
|
181
|
+
)
|
|
182
|
+
.join('')
|
|
183
|
+
|
|
184
|
+
const anyPriced = data.agents.some((a) => a.estimateUsd !== null)
|
|
185
|
+
|
|
186
|
+
return `<!doctype html>
|
|
187
|
+
<html lang="en">
|
|
188
|
+
<head>
|
|
189
|
+
<meta charset="utf-8">
|
|
190
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
191
|
+
<title>konvoy dashboard — ${title}</title>
|
|
192
|
+
<style>${STYLE}</style>
|
|
193
|
+
</head>
|
|
194
|
+
<body>
|
|
195
|
+
<h1>${title}</h1>
|
|
196
|
+
<p class="totals">${data.totals.turns} turns · ${data.totals.inputTokens} in · ${data.totals.outputTokens} out</p>
|
|
197
|
+
|
|
198
|
+
<table>
|
|
199
|
+
<thead><tr><th>AGENT</th><th>TURNS</th><th>IN</th><th>OUT</th><th>SPEND</th><th>~USD</th></tr></thead>
|
|
200
|
+
<tbody>${agentRows}</tbody>
|
|
201
|
+
</table>
|
|
202
|
+
${anyPriced ? `<p class="note">~USD is estimated from rates configured as of ${escape(data.asOf || 'an unspecified date')}</p>` : ''}
|
|
203
|
+
|
|
204
|
+
<h2>turns per day</h2>
|
|
205
|
+
${totalsBarsSvg(data.days)}
|
|
206
|
+
|
|
207
|
+
<h2>turns per day by agent</h2>
|
|
208
|
+
${agentBarsSvg(data.byAgentDay)}
|
|
209
|
+
</body>
|
|
210
|
+
</html>`
|
|
211
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { AgentId } from './types'
|
|
2
|
+
import type { UsageRow } from './store/queries'
|
|
3
|
+
import { estimateAgentUsd, isPricingConfigured, type ModelUsage, type Pricing } from './pricing'
|
|
4
|
+
|
|
5
|
+
export interface RosterRow {
|
|
6
|
+
agent: AgentId
|
|
7
|
+
status: string
|
|
8
|
+
model: string
|
|
9
|
+
effort: string
|
|
10
|
+
foreignId: string | null
|
|
11
|
+
turns: number
|
|
12
|
+
costUsd: number
|
|
13
|
+
credits: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function table(header: string[], rows: string[][]): string {
|
|
17
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)))
|
|
18
|
+
const line = (cells: string[]) => cells.map((c, i) => c.padEnd(widths[i]!)).join(' ').trimEnd()
|
|
19
|
+
return [line(header), ...rows.map(line)].join('\n') + '\n'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatRoster(rows: RosterRow[]): string {
|
|
23
|
+
return table(
|
|
24
|
+
['AGENT', 'STATUS', 'MODEL', 'EFFORT', 'SESSION', 'TURNS', 'COST'],
|
|
25
|
+
rows.map((r) => [r.agent, r.status, r.model || '-', r.effort, r.foreignId ?? '-', String(r.turns), cost(r)]),
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function cost(r: RosterRow): string {
|
|
30
|
+
if (r.costUsd > 0) return `$${r.costUsd.toFixed(2)}`
|
|
31
|
+
if (r.credits > 0) return `${r.credits.toFixed(3)} cr`
|
|
32
|
+
return '-'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// exported so the dashboard renders the same string the terminal does, rather than
|
|
36
|
+
// reimplementing the credits-before-dollars rule and risking the two drifting apart
|
|
37
|
+
export function spend(row: UsageRow): string {
|
|
38
|
+
// credits win when both are non-zero: they're what the agent actually charged, and the
|
|
39
|
+
// cost estimator derives its dollar figure from credits the same way — the two must
|
|
40
|
+
// never disagree about which number is the real one for a given row.
|
|
41
|
+
if (row.credits > 0) return `${row.credits.toFixed(3)} cr`
|
|
42
|
+
if (row.costUsd > 0) return `$${row.costUsd.toFixed(2)}`
|
|
43
|
+
return '-'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function usdCell(row: UsageRow, modelRows: ModelUsage[], pricing: Pricing): string {
|
|
47
|
+
const est = estimateAgentUsd(row.agent, modelRows, pricing)
|
|
48
|
+
if (est === null) return '-'
|
|
49
|
+
// an already-real dollar figure keeps money's usual 2 decimals; a credit- or token-derived
|
|
50
|
+
// estimate gets the extra precision those small figures are shown with, or it rounds to nothing
|
|
51
|
+
return row.costUsd > 0 ? `$${est.toFixed(2)}` : `$${est.toFixed(3)}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function formatUsage(rows: UsageRow[], pricing?: Pricing, modelRows: ModelUsage[] = []): string {
|
|
55
|
+
const showUsd = pricing !== undefined && isPricingConfigured(pricing)
|
|
56
|
+
const header = ['AGENT', 'TURNS', 'IN', 'OUT', 'SPEND', ...(showUsd ? ['~USD'] : []), 'GATE']
|
|
57
|
+
return table(
|
|
58
|
+
header,
|
|
59
|
+
rows.map((r) => [
|
|
60
|
+
r.agent,
|
|
61
|
+
String(r.turns),
|
|
62
|
+
String(r.inputTokens),
|
|
63
|
+
String(r.outputTokens),
|
|
64
|
+
spend(r),
|
|
65
|
+
...(showUsd ? [usdCell(r, modelRows, pricing!)] : []),
|
|
66
|
+
r.gateKnown > 0 ? `${r.gatePassed}/${r.gateKnown}` : '-',
|
|
67
|
+
]),
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function duplicateModels(rows: RosterRow[]): string[] {
|
|
72
|
+
const counts = new Map<string, number>()
|
|
73
|
+
for (const r of rows) {
|
|
74
|
+
if (!r.model) continue
|
|
75
|
+
counts.set(r.model, (counts.get(r.model) ?? 0) + 1)
|
|
76
|
+
}
|
|
77
|
+
return [...counts.entries()].filter(([, n]) => n > 1).map(([model]) => model)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface AgentStatusRow {
|
|
81
|
+
agent: AgentId
|
|
82
|
+
installed: boolean
|
|
83
|
+
version: string | null
|
|
84
|
+
authed: boolean | null
|
|
85
|
+
detail?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function formatVersions(rows: AgentStatusRow[]): string {
|
|
89
|
+
return table(
|
|
90
|
+
['AGENT', 'VERSION', 'AUTH', 'DETAIL'],
|
|
91
|
+
rows.map((r) => [
|
|
92
|
+
r.agent,
|
|
93
|
+
r.installed ? (r.version ?? 'unknown') : 'not installed',
|
|
94
|
+
r.authed === null ? 'unknown' : r.authed ? 'ok' : 'login required',
|
|
95
|
+
r.detail ?? '',
|
|
96
|
+
]),
|
|
97
|
+
)
|
|
98
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function join(...parts: string[]): string {
|
|
2
|
+
const absolute = parts[0]?.startsWith('/') ?? false
|
|
3
|
+
const segments: string[] = []
|
|
4
|
+
for (const part of parts) {
|
|
5
|
+
for (const segment of part.split('/')) {
|
|
6
|
+
if (segment === '' || segment === '.') continue
|
|
7
|
+
if (segment === '..') {
|
|
8
|
+
segments.pop()
|
|
9
|
+
continue
|
|
10
|
+
}
|
|
11
|
+
segments.push(segment)
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return (absolute ? '/' : '') + segments.join('/')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function dirname(p: string): string {
|
|
18
|
+
const cut = p.lastIndexOf('/')
|
|
19
|
+
if (cut < 0) return '.'
|
|
20
|
+
if (cut === 0) return '/'
|
|
21
|
+
return p.slice(0, cut)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function home(): string {
|
|
25
|
+
const h = Bun.env.HOME
|
|
26
|
+
if (!h) throw new Error('HOME is not set')
|
|
27
|
+
return h
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const configDir = (): string => join(home(), '.config', 'konvoy')
|
|
31
|
+
const dataDir = (): string => join(home(), '.local', 'share', 'konvoy')
|
|
32
|
+
export const dbPath = (): string => join(dataDir(), 'konvoy.db')
|
|
33
|
+
export const sessionDir = (cwd: string, slug: string): string => join(cwd, '.konvoy', slug)
|
package/src/pricing.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { AgentId } from './types'
|
|
2
|
+
import type { Config } from './config/schema'
|
|
3
|
+
|
|
4
|
+
export type Pricing = Config['pricing']
|
|
5
|
+
|
|
6
|
+
export interface Priced {
|
|
7
|
+
agent: AgentId
|
|
8
|
+
model: string | null
|
|
9
|
+
inputTokens: number
|
|
10
|
+
outputTokens: number
|
|
11
|
+
credits: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function estimateUsd(row: Priced, pricing: Pricing): number | null {
|
|
15
|
+
// An agent bills one way or the other, never both. Kiro reports token counts alongside the
|
|
16
|
+
// credits it actually charges, so pricing its model as well would double the row.
|
|
17
|
+
if (row.credits > 0) {
|
|
18
|
+
const rate = pricing.credits[row.agent]
|
|
19
|
+
return rate ? row.credits * rate.usdPerCredit : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (row.model) {
|
|
23
|
+
const rate = pricing.models[row.model]
|
|
24
|
+
if (rate) {
|
|
25
|
+
// inputTokens is the whole context sent, cache reads included, and those bill at a
|
|
26
|
+
// tenth of this rate — so a cache-heavy turn estimates high. Only turns whose CLI
|
|
27
|
+
// reported no cost of its own reach here, which today is never claude's.
|
|
28
|
+
return (row.inputTokens / 1_000_000) * rate.inputPerMTok +
|
|
29
|
+
(row.outputTokens / 1_000_000) * rate.outputPerMTok
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// pricing defaults to empty, so an unconfigured user must never see a ~USD column of dashes —
|
|
37
|
+
// the column exists only once there's at least one rate to estimate from
|
|
38
|
+
export function isPricingConfigured(pricing: Pricing): boolean {
|
|
39
|
+
return Object.keys(pricing.models).length > 0 || Object.keys(pricing.credits).length > 0
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ModelUsage {
|
|
43
|
+
agent: AgentId
|
|
44
|
+
model: string | null
|
|
45
|
+
inputTokens: number
|
|
46
|
+
outputTokens: number
|
|
47
|
+
costUsd: number
|
|
48
|
+
credits: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Summed per model, never priced once on the aggregate: a session that ran half its turns on
|
|
52
|
+
// an expensive model and half on a cheap one must not be priced as though it used either one
|
|
53
|
+
// throughout — that's the whole reason `model` lives on the turn instead of the binding.
|
|
54
|
+
export function estimateAgentUsd(agent: AgentId, rows: ModelUsage[], pricing: Pricing): number | null {
|
|
55
|
+
const mine = rows.filter((r) => r.agent === agent)
|
|
56
|
+
// An agent bills one way or the other. Kiro charges credits and reports token counts beside
|
|
57
|
+
// them for information; codex and opencode report only tokens. Deciding per agent rather than
|
|
58
|
+
// per row is what keeps a kiro turn that charged no credits from reading as unpriceable, and
|
|
59
|
+
// a codex turn with real tokens and no model from reading as free.
|
|
60
|
+
const billsInCredits = mine.some((r) => r.credits > 0)
|
|
61
|
+
|
|
62
|
+
let total = 0
|
|
63
|
+
for (const row of mine) {
|
|
64
|
+
if (row.costUsd > 0) {
|
|
65
|
+
total += row.costUsd
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (billsInCredits) {
|
|
70
|
+
if (row.credits === 0) continue
|
|
71
|
+
const rate = pricing.credits[agent]
|
|
72
|
+
if (!rate) return null
|
|
73
|
+
total += row.credits * rate.usdPerCredit
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (row.inputTokens === 0 && row.outputTokens === 0) continue
|
|
78
|
+
const est = estimateUsd(
|
|
79
|
+
{ agent, model: row.model, inputTokens: row.inputTokens, outputTokens: row.outputTokens, credits: row.credits },
|
|
80
|
+
pricing,
|
|
81
|
+
)
|
|
82
|
+
if (est === null) return null
|
|
83
|
+
total += est
|
|
84
|
+
}
|
|
85
|
+
return total
|
|
86
|
+
}
|
package/src/store/db.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite'
|
|
2
|
+
import { dirname } from '../paths'
|
|
3
|
+
|
|
4
|
+
const MIGRATIONS: string[] = [
|
|
5
|
+
`CREATE TABLE session (
|
|
6
|
+
id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, goal TEXT NOT NULL,
|
|
7
|
+
cwd TEXT NOT NULL, lead TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
|
|
8
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
|
|
9
|
+
updated_seq INTEGER NOT NULL DEFAULT 0);
|
|
10
|
+
CREATE TABLE binding (
|
|
11
|
+
session_id TEXT NOT NULL, agent TEXT NOT NULL, foreign_id TEXT,
|
|
12
|
+
model TEXT, effort TEXT NOT NULL, permission TEXT NOT NULL,
|
|
13
|
+
status TEXT NOT NULL, turns INTEGER NOT NULL DEFAULT 0,
|
|
14
|
+
cost_usd REAL NOT NULL DEFAULT 0, credits REAL NOT NULL DEFAULT 0, last_seen INTEGER,
|
|
15
|
+
PRIMARY KEY (session_id, agent));
|
|
16
|
+
CREATE TABLE turn (
|
|
17
|
+
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, agent TEXT NOT NULL,
|
|
18
|
+
prompt TEXT NOT NULL, final TEXT NOT NULL, cost_usd REAL NOT NULL DEFAULT 0,
|
|
19
|
+
credits REAL NOT NULL DEFAULT 0, input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
20
|
+
output_tokens INTEGER NOT NULL DEFAULT 0, kind TEXT, gate_passed INTEGER,
|
|
21
|
+
exit_code INTEGER NOT NULL, error TEXT, error_kind TEXT,
|
|
22
|
+
started_at INTEGER NOT NULL, ended_at INTEGER NOT NULL);
|
|
23
|
+
CREATE INDEX turn_kind_agent ON turn(kind, agent);
|
|
24
|
+
CREATE TABLE lock (
|
|
25
|
+
session_id TEXT PRIMARY KEY, owner TEXT NOT NULL, pid INTEGER NOT NULL,
|
|
26
|
+
acquired_at INTEGER NOT NULL);
|
|
27
|
+
CREATE TABLE event (
|
|
28
|
+
turn_id TEXT NOT NULL, seq INTEGER NOT NULL, type TEXT NOT NULL,
|
|
29
|
+
payload TEXT NOT NULL, ts INTEGER NOT NULL, PRIMARY KEY (turn_id, seq));`,
|
|
30
|
+
`ALTER TABLE turn ADD COLUMN parent_turn_id TEXT;
|
|
31
|
+
ALTER TABLE turn ADD COLUMN model TEXT;`,
|
|
32
|
+
`CREATE INDEX turn_session_id ON turn(session_id);
|
|
33
|
+
CREATE INDEX session_cwd_status ON session(cwd, status);`,
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
export function openDb(path: string): Database {
|
|
37
|
+
if (path !== ':memory:') {
|
|
38
|
+
const dir = dirname(path)
|
|
39
|
+
Bun.spawnSync(['mkdir', '-p', dir])
|
|
40
|
+
}
|
|
41
|
+
let db: Database
|
|
42
|
+
try {
|
|
43
|
+
db = new Database(path, { create: true, strict: true })
|
|
44
|
+
if (path !== ':memory:') {
|
|
45
|
+
// konvoy runs concurrently by design — a nested konvoy inside an agent, a dashboard beside
|
|
46
|
+
// a send. Set before anything that takes a lock: switching a fresh file to WAL needs an
|
|
47
|
+
// exclusive one, and without the timeout the losers of that first statement throw
|
|
48
|
+
db.exec('PRAGMA busy_timeout = 5000')
|
|
49
|
+
// the journal-mode switch needs an exclusive lock and SQLite does not run the busy handler
|
|
50
|
+
// for it, so with several openers racing the losers get SQLITE_BUSY on this one statement.
|
|
51
|
+
// The mode is persistent in the file: the winner's switch holds for everyone, a loser moves on.
|
|
52
|
+
try {
|
|
53
|
+
db.exec('PRAGMA journal_mode = WAL')
|
|
54
|
+
} catch {
|
|
55
|
+
// another opener is switching it right now
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// one transaction for every pending migration: a loser of the open race re-reads
|
|
59
|
+
// user_version under the write lock and finds nothing left to do, and a process that dies
|
|
60
|
+
// mid-migration leaves no half-applied schema behind
|
|
61
|
+
db.exec('BEGIN IMMEDIATE')
|
|
62
|
+
try {
|
|
63
|
+
const current = (db.query('PRAGMA user_version').get() as { user_version: number }).user_version
|
|
64
|
+
for (let v = current; v < MIGRATIONS.length; v++) {
|
|
65
|
+
db.exec(MIGRATIONS[v]!)
|
|
66
|
+
db.exec(`PRAGMA user_version = ${v + 1}`)
|
|
67
|
+
}
|
|
68
|
+
db.exec('COMMIT')
|
|
69
|
+
} catch (error) {
|
|
70
|
+
db.exec('ROLLBACK')
|
|
71
|
+
throw error
|
|
72
|
+
}
|
|
73
|
+
} catch (error) {
|
|
74
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
75
|
+
throw new Error(`cannot open konvoy database at ${path}: ${message}`)
|
|
76
|
+
}
|
|
77
|
+
return db
|
|
78
|
+
}
|