@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,434 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import type { AgentId, Binding, Permission, Session } from '../types'
|
|
3
|
+
import type { ModelUsage } from '../pricing'
|
|
4
|
+
|
|
5
|
+
const now = () => Date.now()
|
|
6
|
+
const id = () => crypto.randomUUID()
|
|
7
|
+
|
|
8
|
+
export function createSession(
|
|
9
|
+
db: Database,
|
|
10
|
+
input: { slug: string; goal: string; cwd: string; lead: AgentId },
|
|
11
|
+
): Session {
|
|
12
|
+
const row: Session = {
|
|
13
|
+
id: id(),
|
|
14
|
+
slug: input.slug,
|
|
15
|
+
goal: input.goal,
|
|
16
|
+
cwd: input.cwd,
|
|
17
|
+
lead: input.lead,
|
|
18
|
+
status: 'active',
|
|
19
|
+
createdAt: now(),
|
|
20
|
+
updatedAt: now(),
|
|
21
|
+
}
|
|
22
|
+
db.query(
|
|
23
|
+
`INSERT INTO session (id, slug, goal, cwd, lead, status, created_at, updated_at, updated_seq)
|
|
24
|
+
VALUES ($id, $slug, $goal, $cwd, $lead, $status, $createdAt, $updatedAt,
|
|
25
|
+
(SELECT COALESCE(MAX(updated_seq), 0) + 1 FROM session))`,
|
|
26
|
+
).run({
|
|
27
|
+
id: row.id,
|
|
28
|
+
slug: row.slug,
|
|
29
|
+
goal: row.goal,
|
|
30
|
+
cwd: row.cwd,
|
|
31
|
+
lead: row.lead,
|
|
32
|
+
status: row.status,
|
|
33
|
+
createdAt: row.createdAt,
|
|
34
|
+
updatedAt: row.updatedAt,
|
|
35
|
+
})
|
|
36
|
+
return row
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const toSession = (r: Record<string, unknown> | null): Session | null =>
|
|
40
|
+
r
|
|
41
|
+
? {
|
|
42
|
+
id: r.id as string,
|
|
43
|
+
slug: r.slug as string,
|
|
44
|
+
goal: r.goal as string,
|
|
45
|
+
cwd: r.cwd as string,
|
|
46
|
+
lead: r.lead as AgentId,
|
|
47
|
+
status: r.status as Session['status'],
|
|
48
|
+
createdAt: r.created_at as number,
|
|
49
|
+
updatedAt: r.updated_at as number,
|
|
50
|
+
}
|
|
51
|
+
: null
|
|
52
|
+
|
|
53
|
+
export function getSessionBySlug(db: Database, slug: string): Session | null {
|
|
54
|
+
return toSession(db.query('SELECT * FROM session WHERE slug = $slug').get({ slug }) as never)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function listSessions(db: Database): Session[] {
|
|
58
|
+
const rows = db.query('SELECT * FROM session ORDER BY created_at DESC, rowid DESC').all() as never[]
|
|
59
|
+
return rows.map((r) => toSession(r)!).filter(Boolean)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function currentSession(db: Database, cwd: string): Session | null {
|
|
63
|
+
return toSession(
|
|
64
|
+
db.query('SELECT * FROM session WHERE cwd = $cwd AND status = $status ORDER BY updated_seq DESC LIMIT 1').get({
|
|
65
|
+
cwd,
|
|
66
|
+
status: 'active',
|
|
67
|
+
}) as never,
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function touchSession(db: Database, id: string): void {
|
|
72
|
+
db.query(
|
|
73
|
+
`UPDATE session SET updated_at = $now,
|
|
74
|
+
updated_seq = (SELECT COALESCE(MAX(updated_seq), 0) + 1 FROM session)
|
|
75
|
+
WHERE id = $id`,
|
|
76
|
+
).run({ id, now: now() })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function deleteSession(db: Database, id: string): void {
|
|
80
|
+
db.transaction(() => {
|
|
81
|
+
db.query('DELETE FROM event WHERE turn_id IN (SELECT id FROM turn WHERE session_id = $id)').run({ id })
|
|
82
|
+
db.query('DELETE FROM turn WHERE session_id = $id').run({ id })
|
|
83
|
+
db.query('DELETE FROM binding WHERE session_id = $id').run({ id })
|
|
84
|
+
db.query('DELETE FROM lock WHERE session_id = $id').run({ id })
|
|
85
|
+
db.query('DELETE FROM session WHERE id = $id').run({ id })
|
|
86
|
+
})()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Every foreign id konvoy has met: UUIDs, kiro's cli_<uuid>_<suffix>, opencode's ses_…. An id
|
|
90
|
+
// is replayed onto a command line — after a flag by three adapters, as a bare positional by
|
|
91
|
+
// codex — so anything else, a leading dash above all, is refused here at the one write path.
|
|
92
|
+
const FOREIGN_ID = /^[A-Za-z0-9][\w.:-]*$/
|
|
93
|
+
|
|
94
|
+
export function upsertBinding(
|
|
95
|
+
db: Database,
|
|
96
|
+
input: {
|
|
97
|
+
sessionId: string
|
|
98
|
+
agent: AgentId
|
|
99
|
+
foreignId: string | null
|
|
100
|
+
effort: string
|
|
101
|
+
permission: Permission
|
|
102
|
+
model?: string | null
|
|
103
|
+
/** set by a turn that failed on auth — the roster then says which agent needs a login */
|
|
104
|
+
status?: 'auth_required'
|
|
105
|
+
},
|
|
106
|
+
): void {
|
|
107
|
+
const foreignId = input.foreignId !== null && !FOREIGN_ID.test(input.foreignId) ? null : input.foreignId
|
|
108
|
+
if (foreignId === null && input.foreignId !== null) {
|
|
109
|
+
console.error(
|
|
110
|
+
`konvoy: ignoring foreign session id ${JSON.stringify(input.foreignId.slice(0, 60))} for ${input.agent} — not a shape konvoy places on a command line`,
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
db.query(
|
|
114
|
+
`INSERT INTO binding (session_id, agent, foreign_id, model, effort, permission, status, last_seen)
|
|
115
|
+
VALUES ($sessionId, $agent, $foreignId, $model, $effort, $permission, $status, $lastSeen)
|
|
116
|
+
ON CONFLICT (session_id, agent) DO UPDATE SET
|
|
117
|
+
foreign_id = COALESCE(excluded.foreign_id, binding.foreign_id),
|
|
118
|
+
model = COALESCE(excluded.model, binding.model),
|
|
119
|
+
effort = excluded.effort,
|
|
120
|
+
permission = excluded.permission,
|
|
121
|
+
status = CASE WHEN excluded.status = 'auth_required' THEN 'auth_required'
|
|
122
|
+
WHEN COALESCE(excluded.foreign_id, binding.foreign_id) IS NOT NULL
|
|
123
|
+
THEN 'bound' ELSE 'unbound' END,
|
|
124
|
+
last_seen = excluded.last_seen`,
|
|
125
|
+
).run({
|
|
126
|
+
sessionId: input.sessionId,
|
|
127
|
+
agent: input.agent,
|
|
128
|
+
foreignId,
|
|
129
|
+
model: input.model ?? null,
|
|
130
|
+
effort: input.effort,
|
|
131
|
+
permission: input.permission,
|
|
132
|
+
status: input.status ?? (foreignId ? 'bound' : 'unbound'),
|
|
133
|
+
lastSeen: now(),
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const toBinding = (r: Record<string, unknown> | null): Binding | null =>
|
|
138
|
+
r
|
|
139
|
+
? {
|
|
140
|
+
sessionId: r.session_id as string,
|
|
141
|
+
agent: r.agent as AgentId,
|
|
142
|
+
foreignId: (r.foreign_id as string | null) ?? null,
|
|
143
|
+
model: (r.model as string | null) ?? null,
|
|
144
|
+
effort: r.effort as string,
|
|
145
|
+
permission: r.permission as Permission,
|
|
146
|
+
status: r.status as Binding['status'],
|
|
147
|
+
turns: r.turns as number,
|
|
148
|
+
costUsd: r.cost_usd as number,
|
|
149
|
+
credits: r.credits as number,
|
|
150
|
+
lastSeen: (r.last_seen as number | null) ?? null,
|
|
151
|
+
}
|
|
152
|
+
: null
|
|
153
|
+
|
|
154
|
+
export function getBinding(db: Database, sessionId: string, agent: AgentId): Binding | null {
|
|
155
|
+
return toBinding(
|
|
156
|
+
db.query('SELECT * FROM binding WHERE session_id = $sessionId AND agent = $agent').get({
|
|
157
|
+
sessionId,
|
|
158
|
+
agent,
|
|
159
|
+
}) as never,
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function listBindings(db: Database, sessionId: string): Binding[] {
|
|
164
|
+
const rows = db.query('SELECT * FROM binding WHERE session_id = $sessionId ORDER BY agent').all({
|
|
165
|
+
sessionId,
|
|
166
|
+
}) as never[]
|
|
167
|
+
return rows.map((r) => toBinding(r)!).filter(Boolean)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// one query for every session's bound-agent count, instead of one `listBindings` per session
|
|
171
|
+
export function boundBindingCounts(db: Database): Map<string, number> {
|
|
172
|
+
const rows = db
|
|
173
|
+
.query('SELECT session_id, COUNT(*) AS bound FROM binding WHERE foreign_id IS NOT NULL GROUP BY session_id')
|
|
174
|
+
.all() as { session_id: string; bound: number }[]
|
|
175
|
+
return new Map(rows.map((r) => [r.session_id, r.bound]))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function clearForeignId(db: Database, sessionId: string, agent: AgentId): void {
|
|
179
|
+
db.query(
|
|
180
|
+
"UPDATE binding SET foreign_id = NULL, status = 'unbound' WHERE session_id = $sessionId AND agent = $agent",
|
|
181
|
+
).run({ sessionId, agent })
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function recordTurn(
|
|
185
|
+
db: Database,
|
|
186
|
+
input: {
|
|
187
|
+
sessionId: string
|
|
188
|
+
agent: AgentId
|
|
189
|
+
prompt: string
|
|
190
|
+
final: string
|
|
191
|
+
costUsd: number
|
|
192
|
+
exitCode: number
|
|
193
|
+
error?: string | null
|
|
194
|
+
credits?: number
|
|
195
|
+
inputTokens?: number
|
|
196
|
+
outputTokens?: number
|
|
197
|
+
kind?: string | null
|
|
198
|
+
model?: string | null
|
|
199
|
+
parentTurnId?: string | null
|
|
200
|
+
},
|
|
201
|
+
): string {
|
|
202
|
+
const turnId = id()
|
|
203
|
+
db.query(
|
|
204
|
+
`INSERT INTO turn (id, session_id, agent, prompt, final, cost_usd, credits, input_tokens,
|
|
205
|
+
output_tokens, kind, gate_passed, exit_code, error, error_kind, started_at, ended_at, model,
|
|
206
|
+
parent_turn_id)
|
|
207
|
+
VALUES ($id, $sessionId, $agent, $prompt, $final, $costUsd, $credits, $inputTokens,
|
|
208
|
+
$outputTokens, $kind, NULL, $exitCode, $error, NULL, $startedAt, $endedAt, $model,
|
|
209
|
+
$parentTurnId)`,
|
|
210
|
+
).run({
|
|
211
|
+
id: turnId,
|
|
212
|
+
sessionId: input.sessionId,
|
|
213
|
+
agent: input.agent,
|
|
214
|
+
prompt: input.prompt,
|
|
215
|
+
final: input.final,
|
|
216
|
+
costUsd: input.costUsd,
|
|
217
|
+
credits: input.credits ?? 0,
|
|
218
|
+
inputTokens: input.inputTokens ?? 0,
|
|
219
|
+
outputTokens: input.outputTokens ?? 0,
|
|
220
|
+
kind: input.kind ?? null,
|
|
221
|
+
exitCode: input.exitCode,
|
|
222
|
+
error: input.error ?? null,
|
|
223
|
+
startedAt: now(),
|
|
224
|
+
endedAt: now(),
|
|
225
|
+
model: input.model ?? null,
|
|
226
|
+
parentTurnId: input.parentTurnId ?? null,
|
|
227
|
+
})
|
|
228
|
+
return turnId
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function lastTurnId(db: Database, sessionId: string): string | null {
|
|
232
|
+
const row = db.query('SELECT id FROM turn WHERE session_id = $sessionId ORDER BY rowid DESC LIMIT 1').get({
|
|
233
|
+
sessionId,
|
|
234
|
+
}) as { id: string } | null
|
|
235
|
+
return row?.id ?? null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Who actually produced the most recent turn — which, after a failover move, is not
|
|
239
|
+
// necessarily the agent `send()` was originally asked to run.
|
|
240
|
+
export function lastTurnAgent(db: Database, sessionId: string): AgentId | null {
|
|
241
|
+
const row = db.query('SELECT agent FROM turn WHERE session_id = $sessionId ORDER BY rowid DESC LIMIT 1').get({
|
|
242
|
+
sessionId,
|
|
243
|
+
}) as { agent: AgentId } | null
|
|
244
|
+
return row?.agent ?? null
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function recordEvent(db: Database, turnId: string, seq: number, type: string, payload: unknown): void {
|
|
248
|
+
db.query('INSERT INTO event (turn_id, seq, type, payload, ts) VALUES ($turnId, $seq, $type, $payload, $ts)').run({
|
|
249
|
+
turnId,
|
|
250
|
+
seq,
|
|
251
|
+
type,
|
|
252
|
+
payload: JSON.stringify(payload),
|
|
253
|
+
ts: now(),
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function isAlive(pid: number): boolean {
|
|
258
|
+
try {
|
|
259
|
+
process.kill(pid, 0)
|
|
260
|
+
return true
|
|
261
|
+
} catch {
|
|
262
|
+
return false
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function lockOwner(db: Database, sessionId: string): string | null {
|
|
267
|
+
const row = db.query('SELECT owner, pid FROM lock WHERE session_id = $sessionId').get({ sessionId }) as
|
|
268
|
+
| { owner: string; pid: number }
|
|
269
|
+
| null
|
|
270
|
+
if (!row) return null
|
|
271
|
+
return isAlive(row.pid) ? row.owner : null
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function reclaimStaleLock(db: Database, sessionId: string, owner: string, pid: number): boolean {
|
|
275
|
+
const res = db
|
|
276
|
+
.query('DELETE FROM lock WHERE session_id = $sessionId AND owner = $owner AND pid = $pid')
|
|
277
|
+
.run({ sessionId, owner, pid })
|
|
278
|
+
return res.changes > 0
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function acquireLock(db: Database, sessionId: string, owner: string): boolean {
|
|
282
|
+
const row = db.query('SELECT owner, pid FROM lock WHERE session_id = $sessionId').get({ sessionId }) as
|
|
283
|
+
| { owner: string; pid: number }
|
|
284
|
+
| null
|
|
285
|
+
if (row) {
|
|
286
|
+
if (row.owner === owner) {
|
|
287
|
+
// a lease inherited from a parent that died keeps the parent's pid; adopt this one so no
|
|
288
|
+
// third process reads the lock as stale and reclaims the session mid-turn
|
|
289
|
+
db.query('UPDATE lock SET pid = $pid WHERE session_id = $sessionId').run({ pid: process.pid, sessionId })
|
|
290
|
+
return true
|
|
291
|
+
}
|
|
292
|
+
if (isAlive(row.pid)) return false
|
|
293
|
+
reclaimStaleLock(db, sessionId, row.owner, row.pid)
|
|
294
|
+
}
|
|
295
|
+
const res = db
|
|
296
|
+
.query(
|
|
297
|
+
`INSERT INTO lock (session_id, owner, pid, acquired_at) VALUES ($sessionId, $owner, $pid, $at)
|
|
298
|
+
ON CONFLICT (session_id) DO NOTHING`,
|
|
299
|
+
)
|
|
300
|
+
.run({ sessionId, owner, pid: process.pid, at: now() })
|
|
301
|
+
return res.changes > 0
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function releaseLock(db: Database, sessionId: string, owner: string): void {
|
|
305
|
+
db.query('DELETE FROM lock WHERE session_id = $sessionId AND owner = $owner').run({ sessionId, owner })
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function setGateResult(db: Database, turnId: string, passed: boolean): void {
|
|
309
|
+
db.query('UPDATE turn SET gate_passed = $passed WHERE id = $turnId').run({ turnId, passed: passed ? 1 : 0 })
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function turnExitCode(db: Database, turnId: string): number | null {
|
|
313
|
+
const row = db.query('SELECT exit_code FROM turn WHERE id = $turnId').get({ turnId }) as { exit_code: number } | null
|
|
314
|
+
return row ? row.exit_code : null
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function bumpBinding(
|
|
318
|
+
db: Database,
|
|
319
|
+
sessionId: string,
|
|
320
|
+
agent: AgentId,
|
|
321
|
+
costUsd: number,
|
|
322
|
+
credits = 0,
|
|
323
|
+
): void {
|
|
324
|
+
db.query(
|
|
325
|
+
`UPDATE binding SET turns = turns + 1, cost_usd = cost_usd + $costUsd, credits = credits + $credits,
|
|
326
|
+
last_seen = $lastSeen WHERE session_id = $sessionId AND agent = $agent`,
|
|
327
|
+
).run({ sessionId, agent, costUsd, credits, lastSeen: now() })
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export interface UsageRow {
|
|
331
|
+
agent: AgentId
|
|
332
|
+
turns: number
|
|
333
|
+
inputTokens: number
|
|
334
|
+
outputTokens: number
|
|
335
|
+
costUsd: number
|
|
336
|
+
credits: number
|
|
337
|
+
gatePassed: number
|
|
338
|
+
gateKnown: number
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const USAGE_COLUMNS = `agent,
|
|
342
|
+
COUNT(*) AS turns,
|
|
343
|
+
COALESCE(SUM(input_tokens), 0) AS input_tokens,
|
|
344
|
+
COALESCE(SUM(output_tokens), 0) AS output_tokens,
|
|
345
|
+
COALESCE(SUM(cost_usd), 0) AS cost_usd,
|
|
346
|
+
COALESCE(SUM(credits), 0) AS credits,
|
|
347
|
+
COALESCE(SUM(CASE WHEN gate_passed = 1 THEN 1 ELSE 0 END), 0) AS gate_passed,
|
|
348
|
+
COALESCE(SUM(CASE WHEN gate_passed IS NULL THEN 0 ELSE 1 END), 0) AS gate_known`
|
|
349
|
+
|
|
350
|
+
const toUsage = (r: Record<string, unknown>): UsageRow => ({
|
|
351
|
+
agent: r.agent as AgentId,
|
|
352
|
+
turns: r.turns as number,
|
|
353
|
+
inputTokens: r.input_tokens as number,
|
|
354
|
+
outputTokens: r.output_tokens as number,
|
|
355
|
+
costUsd: r.cost_usd as number,
|
|
356
|
+
credits: r.credits as number,
|
|
357
|
+
gatePassed: r.gate_passed as number,
|
|
358
|
+
gateKnown: r.gate_known as number,
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
export function usageForSession(db: Database, sessionId: string): UsageRow[] {
|
|
362
|
+
const rows = db
|
|
363
|
+
.query(`SELECT ${USAGE_COLUMNS} FROM turn WHERE session_id = $sessionId GROUP BY agent ORDER BY agent`)
|
|
364
|
+
.all({ sessionId }) as Record<string, unknown>[]
|
|
365
|
+
return rows.map(toUsage)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function usageAcrossSessions(db: Database): UsageRow[] {
|
|
369
|
+
const rows = db
|
|
370
|
+
.query(`SELECT ${USAGE_COLUMNS} FROM turn GROUP BY agent ORDER BY agent`)
|
|
371
|
+
.all() as Record<string, unknown>[]
|
|
372
|
+
return rows.map(toUsage)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// same COALESCE/SUM style as USAGE_COLUMNS, on purpose: this and the per-agent totals above
|
|
376
|
+
// must never treat a null differently, or the two views of the same turns would disagree
|
|
377
|
+
const USAGE_BY_MODEL_COLUMNS = `agent,
|
|
378
|
+
model,
|
|
379
|
+
COALESCE(SUM(input_tokens), 0) AS input_tokens,
|
|
380
|
+
COALESCE(SUM(output_tokens), 0) AS output_tokens,
|
|
381
|
+
COALESCE(SUM(cost_usd), 0) AS cost_usd,
|
|
382
|
+
COALESCE(SUM(credits), 0) AS credits`
|
|
383
|
+
|
|
384
|
+
export function usageByAgentModel(db: Database, sessionId?: string): ModelUsage[] {
|
|
385
|
+
const sql = sessionId
|
|
386
|
+
? `SELECT ${USAGE_BY_MODEL_COLUMNS} FROM turn
|
|
387
|
+
WHERE session_id = $sessionId GROUP BY agent, model ORDER BY agent, model`
|
|
388
|
+
: `SELECT ${USAGE_BY_MODEL_COLUMNS} FROM turn GROUP BY agent, model ORDER BY agent, model`
|
|
389
|
+
const rows = (sessionId ? db.query(sql).all({ sessionId }) : db.query(sql).all()) as Record<string, unknown>[]
|
|
390
|
+
return rows.map((r) => ({
|
|
391
|
+
agent: r.agent as AgentId,
|
|
392
|
+
model: (r.model as string | null) ?? null,
|
|
393
|
+
inputTokens: r.input_tokens as number,
|
|
394
|
+
outputTokens: r.output_tokens as number,
|
|
395
|
+
costUsd: r.cost_usd as number,
|
|
396
|
+
credits: r.credits as number,
|
|
397
|
+
}))
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Buckets are cut in JS so the CLI keeps one clock: bun:sqlite's 'localtime' modifier reads
|
|
401
|
+
// libc's zone, which ignores process.env.TZ and needs tzdata on the host.
|
|
402
|
+
function localDay(ms: number): string {
|
|
403
|
+
const d = new Date(ms)
|
|
404
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function startedAt(db: Database, sessionId?: string): { agent: AgentId; started_at: number }[] {
|
|
408
|
+
const sql = sessionId
|
|
409
|
+
? 'SELECT agent, started_at FROM turn WHERE session_id = $sessionId'
|
|
410
|
+
: 'SELECT agent, started_at FROM turn'
|
|
411
|
+
return (sessionId ? db.query(sql).all({ sessionId }) : db.query(sql).all()) as { agent: AgentId; started_at: number }[]
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function countBy<K extends string>(keys: K[]): Map<K, number> {
|
|
415
|
+
const counts = new Map<K, number>()
|
|
416
|
+
for (const k of keys) counts.set(k, (counts.get(k) ?? 0) + 1)
|
|
417
|
+
return new Map([...counts].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function turnsPerDay(db: Database, sessionId?: string): { day: string; count: number }[] {
|
|
421
|
+
const counts = countBy(startedAt(db, sessionId).map((r) => localDay(r.started_at)))
|
|
422
|
+
return [...counts].map(([day, count]) => ({ day, count }))
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function turnsPerDayByAgent(
|
|
426
|
+
db: Database,
|
|
427
|
+
sessionId?: string,
|
|
428
|
+
): { agent: AgentId; day: string; count: number }[] {
|
|
429
|
+
const counts = countBy(startedAt(db, sessionId).map((r) => `${r.agent}\u0000${localDay(r.started_at)}`))
|
|
430
|
+
return [...counts].map(([key, count]) => {
|
|
431
|
+
const [agent, day] = key.split('\u0000') as [AgentId, string]
|
|
432
|
+
return { agent, day, count }
|
|
433
|
+
})
|
|
434
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { AgentId, Effort, Permission, Style } from './config/schema'
|
|
2
|
+
|
|
3
|
+
export type { AgentId, Effort, Permission, Style }
|
|
4
|
+
|
|
5
|
+
export type KonvoyEvent =
|
|
6
|
+
| { t: 'session'; foreignId: string }
|
|
7
|
+
| { t: 'text'; text: string }
|
|
8
|
+
| { t: 'thinking'; text: string }
|
|
9
|
+
| { t: 'tool'; name: string; status: 'start' | 'ok' | 'error' }
|
|
10
|
+
| { t: 'usage'; inputTokens?: number; outputTokens?: number; costUsd?: number; credits?: number }
|
|
11
|
+
| {
|
|
12
|
+
t: 'error'
|
|
13
|
+
message: string
|
|
14
|
+
kind: 'auth' | 'rate' | 'upstream' | 'crash' | 'timeout' | 'interrupted' | 'unknown'
|
|
15
|
+
/** which wire event carried it, when a CLI has more than one — codex: item vs turn.failed */
|
|
16
|
+
source?: string
|
|
17
|
+
}
|
|
18
|
+
| { t: 'done'; final: string }
|
|
19
|
+
|
|
20
|
+
export type BindingStatus = 'unbound' | 'bound' | 'auth_required'
|
|
21
|
+
|
|
22
|
+
export interface Binding {
|
|
23
|
+
sessionId: string
|
|
24
|
+
agent: AgentId
|
|
25
|
+
foreignId: string | null
|
|
26
|
+
model: string | null
|
|
27
|
+
effort: string
|
|
28
|
+
permission: Permission
|
|
29
|
+
status: BindingStatus
|
|
30
|
+
turns: number
|
|
31
|
+
costUsd: number
|
|
32
|
+
credits: number
|
|
33
|
+
lastSeen: number | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Session {
|
|
37
|
+
id: string
|
|
38
|
+
slug: string
|
|
39
|
+
goal: string
|
|
40
|
+
cwd: string
|
|
41
|
+
lead: AgentId
|
|
42
|
+
status: 'active'
|
|
43
|
+
createdAt: number
|
|
44
|
+
updatedAt: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SpawnPlan {
|
|
48
|
+
cmd: string[]
|
|
49
|
+
env?: Record<string, string>
|
|
50
|
+
cwd?: string
|
|
51
|
+
stdin?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TurnContext {
|
|
55
|
+
sessionId: string
|
|
56
|
+
slug: string
|
|
57
|
+
cwd: string
|
|
58
|
+
sessionDir: string
|
|
59
|
+
prompt: string
|
|
60
|
+
prelude?: string
|
|
61
|
+
binding: Binding | null
|
|
62
|
+
model?: string
|
|
63
|
+
effort: string
|
|
64
|
+
permission: Permission
|
|
65
|
+
harness?: 'minimal' | 'inherit'
|
|
66
|
+
bin?: string
|
|
67
|
+
lease?: string
|
|
68
|
+
kind?: string
|
|
69
|
+
style?: Style
|
|
70
|
+
delegation?: boolean
|
|
71
|
+
}
|