| 1 | import { execFile } from 'node:child_process'; |
| 2 | import { homedir } from 'node:os'; |
| 3 | import { join } from 'node:path'; |
| 4 | import { readFileSync } from 'node:fs'; |
| 5 | import { promisify } from 'node:util'; |
| 6 | import type { AgentDef } from '../types.js'; |
| 7 | |
| 8 | const exec = promisify(execFile); |
| 9 | |
| 10 | /** |
| 11 | * Open Design AMR (Vela) agent. |
| 12 | * |
| 13 | * AMR is the `vela` CLI's ACP stdio mode: `vela agent run --runtime opencode` |
| 14 | * starts a private OpenCode server and speaks ACP JSON-RPC over stdio. The user |
| 15 | * authenticates once in the Open Design app (`vela login`, browser OAuth) — we |
| 16 | * reuse that login state, never ask for an API key. |
| 17 | * |
| 18 | * Binary resolution (so every user can use AMR, not just OD installers): |
| 19 | * 1. PATH (`vela`) |
| 20 | * 2. Open Design.app bundle (reuse an existing OD install) |
| 21 | * 3. @powerformer/vela-cli npm package — ships per-platform `vela` binaries, |
| 22 | * so html-video bundles AMR support without any external install. |
| 23 | * ($OPEN_DESIGN_VELA_CLI_BIN overrides everything, matching OD's env knob.) |
| 24 | * |
| 25 | * Login state lives in ~/.vela/config.json (profile → runtimeKey/apiUrl/user); |
| 26 | * each user signs in with their OWN OD/AMR account (browser OAuth via |
| 27 | * `vela login`) — html-video never holds a key. We treat a profile with a |
| 28 | * runtimeKey + `vela whoami` success as "logged in". |
| 29 | * |
| 30 | * Stage 2 (this revision): bundled-binary distribution via vela-cli. |
| 31 | * The ACP JSON-RPC client (initialize → session/new → session/prompt → streamed |
| 32 | * session/update) lands in stage 4. |
| 33 | */ |
| 34 | |
| 35 | const VELA_CLI_BIN_ENV = 'OPEN_DESIGN_VELA_CLI_BIN'; |
| 36 | |
| 37 | const VELA_BUNDLE_FALLBACKS = [ |
| 38 | '/Applications/Open Design.app/Contents/Resources/open-design/bin/vela', |
| 39 | join(homedir(), 'Applications/Open Design.app/Contents/Resources/open-design/bin/vela'), |
| 40 | ]; |
| 41 | |
| 42 | /** Resolve `vela` from the bundled @powerformer/vela-cli npm package (per-platform |
| 43 | * binaries). Returns an absolute path, or null if the package isn't installed / |
| 44 | * has no binary for this platform. */ |
| 45 | async function resolveBundledVela(): Promise<string | null> { |
| 46 | const envOverride = process.env[VELA_CLI_BIN_ENV]?.trim(); |
| 47 | if (envOverride) return envOverride; |
| 48 | try { |
| 49 | const mod = (await import('@powerformer/vela-cli')) as unknown as { |
| 50 | resolveVelaCliBin?: (opts?: { strict?: boolean }) => unknown; |
| 51 | }; |
| 52 | if (typeof mod.resolveVelaCliBin !== 'function') return null; |
| 53 | const resolved = await Promise.resolve(mod.resolveVelaCliBin({ strict: false })); |
| 54 | if (typeof resolved === 'string') return resolved.trim() || null; |
| 55 | if (resolved && typeof resolved === 'object') { |
| 56 | const p = (resolved as { path?: unknown }).path; |
| 57 | if (typeof p === 'string') return p.trim() || null; |
| 58 | } |
| 59 | return null; |
| 60 | } catch { |
| 61 | return null; |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | interface VelaProfile { |
| 66 | runtimeKey?: string; |
| 67 | apiUrl?: string; |
| 68 | user?: { email?: string; plan?: string } | null; |
| 69 | } |
| 70 | |
| 71 | /** Read ~/.vela/config.json and return the active profile (prod by default). */ |
| 72 | export function readVelaProfile(): { name: string; profile: VelaProfile } | null { |
| 73 | try { |
| 74 | const raw = readFileSync(join(homedir(), '.vela', 'config.json'), 'utf8'); |
| 75 | const cfg = JSON.parse(raw) as { profiles?: Record<string, VelaProfile> }; |
| 76 | const profiles = cfg.profiles ?? {}; |
| 77 | const name = process.env.VELA_PROFILE?.trim() || (profiles.prod ? 'prod' : Object.keys(profiles)[0] ?? ''); |
| 78 | const profile = profiles[name]; |
| 79 | return profile ? { name, profile } : null; |
| 80 | } catch { |
| 81 | return null; |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | export const amr: AgentDef = { |
| 86 | id: 'amr', |
| 87 | name: 'Open Design AMR', |
| 88 | bin: 'vela', |
| 89 | binFallbacks: VELA_BUNDLE_FALLBACKS, |
| 90 | resolveBinFallback: resolveBundledVela, |
| 91 | versionArgs: ['--version'], |
| 92 | // ACP stdio runtime: starts a private OpenCode server, talks JSON-RPC. |
| 93 | buildArgs: () => ['agent', 'run', '--runtime', 'opencode'], |
| 94 | streamFormat: 'acp-json-rpc', |
| 95 | // Identify html-video as the host so the vela CLI tags its command + |
| 96 | // model_request analytics with source=html_video (revenue attribution). |
| 97 | env: { AMR_CLIENT_SOURCE: 'html_video' }, |
| 98 | // AMR home; ?source=html_video lets vela's home page_view attribute the visit |
| 99 | // to html-video (same host dimension as the model-spend attribution above). |
| 100 | installUrl: 'https://open-design.ai/amr?source=html_video', |
| 101 | // AMR rejects session/prompt until a model is set. Default to deepseek-v4-flash |
| 102 | // (the "Lower cost / Many models" official pick); overridable per-call later. |
| 103 | defaultModel: 'deepseek-v4-flash', |
| 104 | |
| 105 | // Found on disk → confirm the user is actually logged in. AMR needs no API |
| 106 | // key, but it does need a live `vela login` session. |
| 107 | async extraDetect(resolvedBin: string) { |
| 108 | const prof = readVelaProfile(); |
| 109 | if (!prof || !prof.profile.runtimeKey) { |
| 110 | return { available: false, hint: 'Sign in to AMR in the Open Design app first (vela login).' }; |
| 111 | } |
| 112 | // whoami is the authoritative liveness check — the stored runtimeKey can |
| 113 | // expire even when config.json still looks populated. |
| 114 | try { |
| 115 | const { stdout } = await exec(resolvedBin, ['whoami'], { timeout: 6000 }); |
| 116 | const out = stdout.trim(); |
| 117 | if (/not logged in|run `?vela login`?/i.test(out)) { |
| 118 | return { available: false, hint: 'AMR session expired — re-run vela login in Open Design.' }; |
| 119 | } |
| 120 | const email = prof.profile.user?.email; |
| 121 | const plan = prof.profile.user?.plan; |
| 122 | return { available: true, version: `AMR${email ? ` · ${email}` : ''}${plan ? ` (${plan})` : ''}` }; |
| 123 | } catch (err) { |
| 124 | const msg = err instanceof Error ? err.message : String(err); |
| 125 | if (/not logged in/i.test(msg)) { |
| 126 | return { available: false, hint: 'AMR session expired — re-run vela login in Open Design.' }; |
| 127 | } |
| 128 | return { available: false, hint: `vela whoami failed: ${msg.slice(0, 120)}` }; |
| 129 | } |
| 130 | }, |
| 131 | }; |
| 132 | |
| 133 | /** Preferred ordering — surface the cheap/fast default + flagships first. */ |
| 134 | const AMR_MODEL_RANK: ReadonlyMap<string, number> = new Map( |
| 135 | ['deepseek-v4-flash', 'deepseek-v4-pro', 'claude-opus-4.8', 'claude-sonnet-4.6', 'gpt-5.5', 'gemini-3.1-pro-preview'] |
| 136 | .map((id, i) => [id, i]), |
| 137 | ); |
| 138 | |
| 139 | export interface AmrModel { id: string; label: string } |
| 140 | |
| 141 | /** |
| 142 | * List the live AMR catalog via `vela model list`. Each line is |
| 143 | * `<model-id>\t<provider>`; the id is already the link-facing slug AMR accepts |
| 144 | * in session/set_model, so no normalization is needed. Ordered preferred-first. |
| 145 | */ |
| 146 | export async function listAmrModels(resolvedBin: string): Promise<AmrModel[]> { |
| 147 | const { stdout } = await exec(resolvedBin, ['model', 'list'], { timeout: 10_000, maxBuffer: 1024 * 1024 }); |
| 148 | const seen = new Set<string>(); |
| 149 | const models: AmrModel[] = []; |
| 150 | for (const line of String(stdout).split('\n')) { |
| 151 | const id = line.split('\t')[0]?.trim(); |
| 152 | if (!id || id.startsWith('#') || seen.has(id)) continue; |
| 153 | seen.add(id); |
| 154 | models.push({ id, label: id }); |
| 155 | } |
| 156 | return models.sort((a, b) => { |
| 157 | const ra = AMR_MODEL_RANK.get(a.id) ?? Number.MAX_SAFE_INTEGER; |
| 158 | const rb = AMR_MODEL_RANK.get(b.id) ?? Number.MAX_SAFE_INTEGER; |
| 159 | return ra - rb || a.id.localeCompare(b.id); |
| 160 | }); |
| 161 | } |
| 162 |