@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 { AgentId } from '../types'
|
|
2
|
+
import { getAdapter } from '../adapters'
|
|
3
|
+
import { stripControlChars } from '../adapters/types'
|
|
4
|
+
import { home, join } from '../paths'
|
|
5
|
+
|
|
6
|
+
export interface Detection {
|
|
7
|
+
agent: AgentId
|
|
8
|
+
installed: boolean
|
|
9
|
+
version: string | null
|
|
10
|
+
efforts?: readonly string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface AuthState {
|
|
14
|
+
agent: AgentId
|
|
15
|
+
authed: boolean | null
|
|
16
|
+
detail: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type Runner = (cmd: string[]) => Promise<{ stdout: string; exitCode: number }>
|
|
20
|
+
|
|
21
|
+
export interface DetectDeps {
|
|
22
|
+
run: Runner
|
|
23
|
+
readText: (path: string) => Promise<string | null>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseVersion(text: string): string | null {
|
|
27
|
+
return /(\d+\.\d+\.\d+)/.exec(text)?.[1] ?? null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface AuthCheck {
|
|
31
|
+
args: string[]
|
|
32
|
+
ok: (stdout: string, exitCode: number) => boolean | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const AUTH_CHECK: Record<AgentId, (bin?: string) => AuthCheck> = {
|
|
36
|
+
claude: (bin = 'claude') => ({
|
|
37
|
+
args: [bin, 'auth', 'status'],
|
|
38
|
+
ok: (stdout, exitCode) => {
|
|
39
|
+
if (!stdout) return null
|
|
40
|
+
try {
|
|
41
|
+
const json = JSON.parse(stdout) as { loggedIn?: unknown }
|
|
42
|
+
if (typeof json.loggedIn === 'boolean') return json.loggedIn
|
|
43
|
+
} catch {
|
|
44
|
+
// not JSON, fall through
|
|
45
|
+
}
|
|
46
|
+
return null
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
codex: (bin = 'codex') => ({
|
|
50
|
+
args: [bin, 'login', 'status'],
|
|
51
|
+
ok: (stdout, exitCode) => {
|
|
52
|
+
if (!stdout || exitCode !== 0) return null
|
|
53
|
+
return stdout.includes('Logged in')
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
kiro: (bin = 'kiro-cli') => ({
|
|
57
|
+
args: [bin, 'whoami'],
|
|
58
|
+
ok: (stdout, exitCode) => {
|
|
59
|
+
if (!stdout || exitCode !== 0) return null
|
|
60
|
+
return stdout.includes('Logged in')
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
opencode: (bin = 'opencode') => ({
|
|
64
|
+
args: [bin, 'auth', 'list'],
|
|
65
|
+
ok: (stdout, exitCode) => {
|
|
66
|
+
if (exitCode !== 0) return null
|
|
67
|
+
return stdout.trim().length > 0
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function codexEfforts(deps: DetectDeps, model?: string): Promise<readonly string[] | undefined> {
|
|
73
|
+
if (!model) return undefined
|
|
74
|
+
const raw = await deps.readText(join(home(), '.codex', 'models_cache.json'))
|
|
75
|
+
if (!raw) return undefined
|
|
76
|
+
try {
|
|
77
|
+
const cache = JSON.parse(raw) as { models?: unknown }
|
|
78
|
+
if (!Array.isArray(cache.models)) return undefined
|
|
79
|
+
// the real file (codex 0.155.1, 2026-09-21) keys models by `slug` and lists each level as
|
|
80
|
+
// `{ effort, description }` — see tests/fixtures/codex-models-cache.json
|
|
81
|
+
const hit = (cache.models as { slug?: string; supported_reasoning_levels?: unknown }[]).find(
|
|
82
|
+
(m) => m.slug === model,
|
|
83
|
+
)
|
|
84
|
+
if (!Array.isArray(hit?.supported_reasoning_levels)) return undefined
|
|
85
|
+
const levels = hit.supported_reasoning_levels.flatMap((l) =>
|
|
86
|
+
typeof (l as { effort?: unknown } | null)?.effort === 'string' ? [(l as { effort: string }).effort] : [],
|
|
87
|
+
)
|
|
88
|
+
return levels.length > 0 ? levels : undefined
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function authDetail(stdout: string, exitCode: number): string {
|
|
95
|
+
if (exitCode === 127) return 'not installed'
|
|
96
|
+
const trimmed = stdout.trim()
|
|
97
|
+
if (trimmed.startsWith('{')) {
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(trimmed) as { loggedIn?: unknown; authMethod?: unknown }
|
|
100
|
+
const via = typeof parsed.authMethod === 'string' ? ` via ${stripControlChars(parsed.authMethod)}` : ''
|
|
101
|
+
if (parsed.loggedIn === true) return `logged in${via}`
|
|
102
|
+
if (parsed.loggedIn === false) return 'not logged in'
|
|
103
|
+
} catch {
|
|
104
|
+
// not the shape we expected; the first line is still better than nothing
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return stripControlChars(trimmed.split('\n')[0] ?? '')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function detectWith(
|
|
111
|
+
deps: DetectDeps,
|
|
112
|
+
agent: AgentId,
|
|
113
|
+
opts: { model?: string; bin?: string } = {},
|
|
114
|
+
): Promise<Detection> {
|
|
115
|
+
const adapter = getAdapter(agent)
|
|
116
|
+
const result = await deps.run([opts.bin ?? adapter.bin, '--version'])
|
|
117
|
+
const installed = result.exitCode === 0
|
|
118
|
+
const version = installed ? parseVersion(result.stdout) : null
|
|
119
|
+
const efforts = installed && agent === 'codex' ? await codexEfforts(deps, opts.model) : undefined
|
|
120
|
+
return { agent, installed, version, efforts }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function detectAuthWith(
|
|
124
|
+
deps: DetectDeps,
|
|
125
|
+
agent: AgentId,
|
|
126
|
+
bin?: string,
|
|
127
|
+
): Promise<AuthState> {
|
|
128
|
+
const check = AUTH_CHECK[agent](bin)
|
|
129
|
+
const result = await deps.run(check.args)
|
|
130
|
+
const detail = authDetail(result.stdout, result.exitCode)
|
|
131
|
+
if (result.exitCode === 127) {
|
|
132
|
+
return { agent, authed: null, detail }
|
|
133
|
+
}
|
|
134
|
+
const authed = check.ok(result.stdout, result.exitCode)
|
|
135
|
+
return { agent, authed, detail }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function memo<K, V>(cacheMap: Map<K, Promise<V>>, key: K, f: () => Promise<V>): Promise<V> {
|
|
139
|
+
const hit = cacheMap.get(key)
|
|
140
|
+
if (hit) return hit
|
|
141
|
+
const pending = f()
|
|
142
|
+
.then((v) => {
|
|
143
|
+
cacheMap.set(key, Promise.resolve(v))
|
|
144
|
+
return v
|
|
145
|
+
})
|
|
146
|
+
.catch((e) => {
|
|
147
|
+
cacheMap.delete(key)
|
|
148
|
+
throw e
|
|
149
|
+
})
|
|
150
|
+
cacheMap.set(key, pending)
|
|
151
|
+
return pending
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function realDeps(): DetectDeps {
|
|
155
|
+
return {
|
|
156
|
+
run: async (cmd) => {
|
|
157
|
+
try {
|
|
158
|
+
const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe', timeout: 10_000 })
|
|
159
|
+
const stdout = await new Response(proc.stdout).text()
|
|
160
|
+
const stderr = await new Response(proc.stderr).text()
|
|
161
|
+
const exitCode = await proc.exited
|
|
162
|
+
return { stdout: stdout || stderr, exitCode }
|
|
163
|
+
} catch {
|
|
164
|
+
return { stdout: '', exitCode: 127 }
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
readText: async (path) => {
|
|
168
|
+
try {
|
|
169
|
+
const file = Bun.file(path)
|
|
170
|
+
return (await file.exists()) ? file.text() : null
|
|
171
|
+
} catch {
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const detectCacheMap = new Map<string, Promise<Detection>>()
|
|
179
|
+
const detectAuthCacheMap = new Map<string, Promise<AuthState>>()
|
|
180
|
+
|
|
181
|
+
// Real detection shares one memo; each injected deps object gets its own, so a fake answering in
|
|
182
|
+
// one place is never handed to another.
|
|
183
|
+
const depsIds = new WeakMap<DetectDeps, number>()
|
|
184
|
+
let nextDepsId = 0
|
|
185
|
+
function scope(deps?: DetectDeps): string {
|
|
186
|
+
if (!deps) return 'real'
|
|
187
|
+
let id = depsIds.get(deps)
|
|
188
|
+
if (id === undefined) depsIds.set(deps, (id = ++nextDepsId))
|
|
189
|
+
return String(id)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function clearDetectCache(): void {
|
|
193
|
+
detectCacheMap.clear()
|
|
194
|
+
detectAuthCacheMap.clear()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface DetectOptions {
|
|
198
|
+
model?: string
|
|
199
|
+
bin?: string
|
|
200
|
+
deps?: DetectDeps
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function detect(agent: AgentId, opts: DetectOptions = {}): Promise<Detection> {
|
|
204
|
+
const key = `${scope(opts.deps)}\u0000${agent}\u0000${opts.model ?? ''}\u0000${opts.bin ?? ''}`
|
|
205
|
+
return memo(detectCacheMap, key, () => detectWith(opts.deps ?? realDeps(), agent, opts))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function detectAuth(agent: AgentId, opts: DetectOptions = {}): Promise<AuthState> {
|
|
209
|
+
const key = `${scope(opts.deps)}\u0000${agent}\u0000${opts.bin ?? ''}`
|
|
210
|
+
return memo(detectAuthCacheMap, key, () => detectAuthWith(opts.deps ?? realDeps(), agent, opts.bin))
|
|
211
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import type { Session } from '../types'
|
|
3
|
+
import { usageForSession } from '../store/queries'
|
|
4
|
+
import { spend } from '../format'
|
|
5
|
+
|
|
6
|
+
export interface Facts {
|
|
7
|
+
commits: { sha: string; subject: string }[]
|
|
8
|
+
files: { path: string; added: number; removed: number }[]
|
|
9
|
+
agents: { agent: string; turns: number; spend: string }[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface FactsDeps {
|
|
13
|
+
git: (args: string[], cwd: string) => Promise<string>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseLog(output: string): { sha: string; subject: string }[] {
|
|
17
|
+
return output
|
|
18
|
+
.split('\n')
|
|
19
|
+
.map((line) => line.trim())
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.map((line) => {
|
|
22
|
+
const sp = line.indexOf(' ')
|
|
23
|
+
return sp === -1 ? { sha: line, subject: '' } : { sha: line.slice(0, sp), subject: line.slice(sp + 1) }
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const STAT_LINE = /^\s*(.+?)\s*\|\s*(\d+)\s*(\+*)(-*)\s*$/
|
|
28
|
+
|
|
29
|
+
function parseDiffStat(output: string): { path: string; added: number; removed: number }[] {
|
|
30
|
+
const files: { path: string; added: number; removed: number }[] = []
|
|
31
|
+
for (const line of output.split('\n')) {
|
|
32
|
+
const m = STAT_LINE.exec(line)
|
|
33
|
+
if (!m) continue
|
|
34
|
+
const total = Number(m[2])
|
|
35
|
+
const plus = m[3]!.length
|
|
36
|
+
const minus = m[4]!.length
|
|
37
|
+
// the +/- bar is drawn proportionally to the real counts, not one char per change,
|
|
38
|
+
// so the split has to be recovered from the bar's ratio rather than counted directly
|
|
39
|
+
const added = plus + minus === 0 ? total : Math.round((total * plus) / (plus + minus))
|
|
40
|
+
files.push({ path: m[1]!, added, removed: total - added })
|
|
41
|
+
}
|
|
42
|
+
return files
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function collectFacts(deps: FactsDeps, db: Database, session: Session): Promise<Facts> {
|
|
46
|
+
const [logOut, diffOut] = await Promise.all([
|
|
47
|
+
deps.git(['log', '--oneline', '-n', '100', '--since', new Date(session.createdAt).toISOString()], session.cwd),
|
|
48
|
+
deps.git(['diff', '--stat'], session.cwd),
|
|
49
|
+
])
|
|
50
|
+
const agents = usageForSession(db, session.id).map((r) => ({ agent: r.agent, turns: r.turns, spend: spend(r) }))
|
|
51
|
+
return { commits: parseLog(logOut), files: parseDiffStat(diffOut), agents }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A field is quoted only when it has to be. 48% of this repository's own commit subjects
|
|
55
|
+
// contain a comma, so an unquoted row is the common case, not the edge one — and a shifted
|
|
56
|
+
// row makes every number after it wrong while still looking like a table.
|
|
57
|
+
function cell(value: string): string {
|
|
58
|
+
return /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The turns half of the prelude is capped (prelude.ts); this half must be too, or a busy
|
|
62
|
+
// repository prepends its whole history to every prompt for every agent.
|
|
63
|
+
const FACTS_ROW_CAP = 30
|
|
64
|
+
|
|
65
|
+
function section(name: string, fields: string[], rows: string[][]): string {
|
|
66
|
+
const shown = rows.slice(0, FACTS_ROW_CAP)
|
|
67
|
+
const lines = [`${name}[${shown.length}]{${fields.join(',')}}:`, ...shown.map((r) => r.map(cell).join(','))]
|
|
68
|
+
if (rows.length > shown.length) lines.push(`(+${rows.length - shown.length} more ${name})`)
|
|
69
|
+
return lines.join('\n')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function formatFacts(facts: Facts): string {
|
|
73
|
+
const blocks: string[] = []
|
|
74
|
+
if (facts.commits.length > 0) {
|
|
75
|
+
blocks.push(section('commits', ['sha', 'subject'], facts.commits.map((c) => [c.sha, c.subject])))
|
|
76
|
+
}
|
|
77
|
+
if (facts.files.length > 0) {
|
|
78
|
+
blocks.push(
|
|
79
|
+
section(
|
|
80
|
+
'files',
|
|
81
|
+
['path', 'added', 'removed'],
|
|
82
|
+
facts.files.map((f) => [f.path, String(f.added), String(f.removed)]),
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
if (facts.agents.length > 0) {
|
|
87
|
+
blocks.push(
|
|
88
|
+
section(
|
|
89
|
+
'agents',
|
|
90
|
+
['agent', 'turns', 'spend'],
|
|
91
|
+
facts.agents.map((a) => [a.agent, String(a.turns), a.spend]),
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
return blocks.join('\n\n')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function realFactsDeps(): FactsDeps {
|
|
99
|
+
return {
|
|
100
|
+
git: async (args, cwd) => {
|
|
101
|
+
try {
|
|
102
|
+
// stderr is never read, so it is not piped — a chatty git would fill the pipe and block;
|
|
103
|
+
// and a git that hangs must not hang the turn
|
|
104
|
+
const proc = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'ignore', timeout: 10_000 })
|
|
105
|
+
const stdout = await new Response(proc.stdout).text()
|
|
106
|
+
await proc.exited
|
|
107
|
+
return stdout
|
|
108
|
+
} catch {
|
|
109
|
+
return ''
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/core/gate.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import type { Config } from '../config/schema'
|
|
3
|
+
import type { Session } from '../types'
|
|
4
|
+
import { setGateResult, turnExitCode } from '../store/queries'
|
|
5
|
+
import { DEFAULT_KILL_GRACE_MS, escalateKill } from './children'
|
|
6
|
+
|
|
7
|
+
// A quality check the user chose to run — konvoy has no model of its own, so it never
|
|
8
|
+
// grades the work itself. Bounded so a hung suite can't block konvoy forever.
|
|
9
|
+
const GATE_TIMEOUT_MS = 5 * 60 * 1000
|
|
10
|
+
|
|
11
|
+
export async function runGate(
|
|
12
|
+
db: Database,
|
|
13
|
+
cfg: Config,
|
|
14
|
+
session: Session,
|
|
15
|
+
turnId: string,
|
|
16
|
+
opts: { timeoutMs?: number; killGraceMs?: number } = {},
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
const command = cfg.gate.command
|
|
19
|
+
if (!command) return
|
|
20
|
+
// A turn with nothing for the gate to judge — running it now would blame the block on the work.
|
|
21
|
+
if (turnExitCode(db, turnId) !== 0) return
|
|
22
|
+
|
|
23
|
+
const cmd = splitCommand(command)
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const proc = Bun.spawn(cmd, {
|
|
27
|
+
cwd: session.cwd,
|
|
28
|
+
stdout: 'ignore',
|
|
29
|
+
stderr: 'ignore',
|
|
30
|
+
timeout: opts.timeoutMs ?? GATE_TIMEOUT_MS,
|
|
31
|
+
killSignal: 'SIGTERM',
|
|
32
|
+
})
|
|
33
|
+
const cancelEscalation = escalateKill(proc, (opts.timeoutMs ?? GATE_TIMEOUT_MS) + (opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS))
|
|
34
|
+
try {
|
|
35
|
+
const exitCode = await proc.exited
|
|
36
|
+
setGateResult(db, turnId, exitCode === 0)
|
|
37
|
+
} finally {
|
|
38
|
+
cancelEscalation()
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// could not spawn: a misconfiguration, not a verdict on the work, so nothing is recorded
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Whitespace outside quotes separates arguments; a quoted run is one argument, quotes removed.
|
|
46
|
+
// No shell is involved — the gate is the user's own command, but it still deserves its quotes.
|
|
47
|
+
export function splitCommand(command: string): string[] {
|
|
48
|
+
const out: string[] = []
|
|
49
|
+
let current = ''
|
|
50
|
+
let quote: string | null = null
|
|
51
|
+
let quoted = false
|
|
52
|
+
for (const ch of command) {
|
|
53
|
+
if (quote) {
|
|
54
|
+
if (ch === quote) quote = null
|
|
55
|
+
else current += ch
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
if (ch === '"' || ch === "'") {
|
|
59
|
+
quote = ch
|
|
60
|
+
quoted = true
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (/\s/.test(ch)) {
|
|
64
|
+
if (current || quoted) out.push(current)
|
|
65
|
+
current = ''
|
|
66
|
+
quoted = false
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
current += ch
|
|
70
|
+
}
|
|
71
|
+
if (current || quoted) out.push(current)
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import type { Session } from '../types'
|
|
3
|
+
|
|
4
|
+
export interface Envelope {
|
|
5
|
+
to: string | null
|
|
6
|
+
task: string
|
|
7
|
+
open: string[]
|
|
8
|
+
decisions: string[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Sentinels rather than JSON: a model wraps JSON in prose or fences, and a missing field
|
|
12
|
+
// should degrade to empty rather than fail a parse.
|
|
13
|
+
const ENVELOPE_RE = /<<<konvoy\n([\s\S]*?)>>>/
|
|
14
|
+
const RECIPIENT = /^[a-z][a-z0-9_-]*$/i
|
|
15
|
+
|
|
16
|
+
export function parseEnvelope(final: string): Envelope | null {
|
|
17
|
+
const match = ENVELOPE_RE.exec(final)
|
|
18
|
+
if (!match) return null
|
|
19
|
+
|
|
20
|
+
const env: Envelope = { to: null, task: '', open: [], decisions: [] }
|
|
21
|
+
let list: string[] | null = null
|
|
22
|
+
|
|
23
|
+
for (const raw of (match[1] ?? '').split('\n')) {
|
|
24
|
+
const line = raw.trim()
|
|
25
|
+
if (line === '') continue
|
|
26
|
+
|
|
27
|
+
const item = /^-\s*(.*)$/.exec(line)
|
|
28
|
+
if (item && list) {
|
|
29
|
+
list.push(item[1]!.trim())
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const kv = /^([a-zA-Z]+):\s*(.*)$/.exec(line)
|
|
34
|
+
if (!kv) {
|
|
35
|
+
list = null
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
const key = kv[1]!
|
|
39
|
+
const value = kv[2]!.trim()
|
|
40
|
+
switch (key) {
|
|
41
|
+
case 'to':
|
|
42
|
+
// a recipient is an agent id or a role name — a bare identifier. The delegation
|
|
43
|
+
// instruction quotes the format with "<agent id or role>" in this slot, and an agent
|
|
44
|
+
// explaining what it is not doing repeats it; that names nobody and is no handoff.
|
|
45
|
+
env.to = RECIPIENT.test(value) ? value : null
|
|
46
|
+
list = null
|
|
47
|
+
break
|
|
48
|
+
case 'task':
|
|
49
|
+
env.task = value
|
|
50
|
+
list = null
|
|
51
|
+
break
|
|
52
|
+
case 'open':
|
|
53
|
+
list = env.open
|
|
54
|
+
break
|
|
55
|
+
case 'decisions':
|
|
56
|
+
list = env.decisions
|
|
57
|
+
break
|
|
58
|
+
default:
|
|
59
|
+
list = null
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return env
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface TurnRow {
|
|
67
|
+
agent: string
|
|
68
|
+
prompt: string
|
|
69
|
+
final: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function renderPair(t: TurnRow): string {
|
|
73
|
+
return `${t.agent} was asked: ${t.prompt}\n${t.agent} answered: ${t.final}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderEnvelope(agent: string, env: Envelope): string {
|
|
77
|
+
const lines = [`${agent} handed off to ${env.to ?? 'the next agent'}:`, `task: ${env.task}`]
|
|
78
|
+
if (env.open.length > 0) lines.push('open:', ...env.open.map((o) => `- ${o}`))
|
|
79
|
+
if (env.decisions.length > 0) lines.push('decisions:', ...env.decisions.map((d) => `- ${d}`))
|
|
80
|
+
return lines.join('\n')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Order is goal, then facts, then recent turns: stable first, volatile last, because prompt
|
|
84
|
+
// caching discounts a stable prefix by roughly an order of magnitude and a prelude that
|
|
85
|
+
// reshuffles itself every turn pays full price for all of it.
|
|
86
|
+
export function buildPrelude(db: Database, session: Session, facts: string, opts: { recent: number }): string {
|
|
87
|
+
const total = (
|
|
88
|
+
db.query('SELECT COUNT(*) AS c FROM turn WHERE session_id = $id').get({ id: session.id }) as { c: number }
|
|
89
|
+
).c
|
|
90
|
+
if (total === 0) return ''
|
|
91
|
+
|
|
92
|
+
const rows = db
|
|
93
|
+
.query('SELECT agent, prompt, final FROM turn WHERE session_id = $id ORDER BY started_at DESC, rowid DESC LIMIT $limit')
|
|
94
|
+
.all({ id: session.id, limit: opts.recent }) as TurnRow[]
|
|
95
|
+
const oldestFirst = [...rows].reverse()
|
|
96
|
+
const dropped = total - rows.length
|
|
97
|
+
|
|
98
|
+
const last = oldestFirst[oldestFirst.length - 1]
|
|
99
|
+
const envelope = last ? parseEnvelope(last.final) : null
|
|
100
|
+
|
|
101
|
+
const turnBlocks: string[] = []
|
|
102
|
+
for (let i = 0; i < oldestFirst.length - 1; i++) turnBlocks.push(renderPair(oldestFirst[i]!))
|
|
103
|
+
|
|
104
|
+
// the same test followHandoff applies: a block with no recipient handed nothing to anyone,
|
|
105
|
+
// so the answer it sits in is what the next agent must see
|
|
106
|
+
if (!last) {
|
|
107
|
+
// recent: 0 — only the goal, the facts and the count of what was left out
|
|
108
|
+
} else if (envelope?.to) {
|
|
109
|
+
turnBlocks.push(renderEnvelope(last.agent, envelope))
|
|
110
|
+
} else {
|
|
111
|
+
turnBlocks.push(renderPair(last))
|
|
112
|
+
// The cooperative case (section 21) has a sender who can still speak; failover does not.
|
|
113
|
+
// A receiver that believes its context is complete proceeds on half the picture, so the
|
|
114
|
+
// gap is stated plainly instead of silently filled with a prompt-and-answer transcript.
|
|
115
|
+
turnBlocks.push("the previous agent's intent was not recorded here — only what was asked and answered is known.")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (dropped > 0) turnBlocks.push(`(${dropped} earlier turn${dropped === 1 ? '' : 's'} not shown)`)
|
|
119
|
+
|
|
120
|
+
return [`goal: ${session.goal}`, facts, turnBlocks.join('\n\n')].filter(Boolean).join('\n\n')
|
|
121
|
+
}
|