ava-engine 0.1.0 → 0.3.0

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/.env.example CHANGED
@@ -16,11 +16,22 @@ PORT=5190
16
16
  # ACCESS_PASSWORD=
17
17
  # TRUST_PROXY=1 # behind Fly/Cloudflare/nginx: rate-limit by the real client IP
18
18
  # SESSION_BUDGET_USD=2 # model spend cap per call
19
+ # DAILY_BUDGET_USD=20 # model spend cap per UTC day across all calls (unset = none)
19
20
  # MAX_SESSIONS=50 # concurrent calls (idle ones expire after 30 min)
20
21
  # MAX_ASR_CONNECTIONS=40 # concurrent speech-to-text streams
21
22
  # RATE_SESSIONS_PER_MIN=10 # new calls per client IP per minute
22
23
  # RATE_API_PER_MIN=240 # other API requests per client IP per minute
23
24
 
25
+ # ── Your products ──
26
+ # PLAYBOOKS_DIR=/srv/ava/playbooks # one JSON file per product (default ~/.ava-engine/playbooks): npx ava-engine playbooks new <id>
27
+ # BUILTIN_PLAYBOOKS=off # hide the built-in sample products from the lobby
28
+
29
+ # ── Events & handoff alerts ──
30
+ # WEBHOOK_URL=https://your-app.example/ava-events # receives every call event as JSON (comma-separate several)
31
+ # WEBHOOK_SECRET= # signs deliveries: X-Ava-Signature: sha256=HMAC(secret, "<timestamp>.<body>")
32
+ # SLACK_WEBHOOK_URL= # Slack incoming webhook: posts when a customer asks for a person
33
+ # PUBLIC_URL=https://ava.your-site.example # base for the specialist links in those alerts
34
+
24
35
  # ── Models (OpenRouter slugs) — one per role ──
25
36
  # MODEL_TALKER=meta-llama/llama-3.3-70b-instruct # the spoken reply (latency-critical)
26
37
  # MODEL_PERCEPTION=google/gemini-2.5-flash # reads the shared screen
package/README.md CHANGED
@@ -11,6 +11,8 @@
11
11
  </p>
12
12
 
13
13
  <p align="center">
14
+ <a href="https://www.npmjs.com/package/ava-engine"><img alt="npm" src="https://img.shields.io/npm/v/ava-engine?color=0A0A0C&label=npm"></a>
15
+ <a href="https://scorecard.dev/viewer/?uri=github.com/Mr-vero/ava-engine"><img alt="OpenSSF Scorecard" src="https://api.scorecard.dev/projects/github.com/Mr-vero/ava-engine/badge"></a>
14
16
  <a href="https://github.com/Mr-vero/ava-engine/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/Mr-vero/ava-engine/actions/workflows/ci.yml/badge.svg"></a>
15
17
  <a href="COMMERCIAL-LICENSE.md"><img alt="License: Ava Engine License (source-available)" src="https://img.shields.io/badge/license-source--available-0A0A0C"></a>
16
18
  <img alt="Node 22.9+" src="https://img.shields.io/badge/node-22.9%2B-4F46E5">
@@ -56,25 +58,25 @@ Requires **Node 22.9+** and desktop **Chrome**. One command installs Ava and wal
56
58
  npx ava-engine
57
59
  ```
58
60
 
59
- The setup wizard:
60
- 1. checks your OpenRouter key (or lets you look around with no keys in MOCK mode);
61
- 2. lets you pick Ava's voice;
62
- 3. downloads offline speech recognition, with a progress bar;
63
- 4. protects Ava with a password if other people will use it;
64
- 5. starts Ava and opens **http://localhost:5190/app**.
61
+ Set up **in your browser** (a step-by-step page) or **in the terminal**; both do the same:
62
+ 1. check your OpenRouter key (or look around with no keys, in MOCK mode);
63
+ 2. pick Ava's voice, and download the free offline voice (Kokoro, ~90 MB) with progress;
64
+ 3. download offline speech recognition, also with progress;
65
+ 4. protect Ava with a password if other people will use it;
66
+ 5. start Ava and open **http://localhost:5190/app**.
65
67
 
66
68
  After that, `npx ava-engine` just starts Ava.
67
69
 
68
70
  | Command | What it does |
69
71
  | --- | --- |
70
- | `npx ava-engine setup` | Re-run the wizard to change keys, voice, password or port |
72
+ | `npx ava-engine setup` | Re-run setup to change keys, voice, password or port (`--web` for the browser, `--terminal` for here) |
71
73
  | `npx ava-engine doctor` | Check everything and get a fix for anything that's wrong |
72
- | `npx ava-engine models` | Add or remove offline speech models |
74
+ | `npx ava-engine models` | Download speech models or the Kokoro voice (`models kokoro`) |
73
75
  | `npx ava-engine start --port 8080` | Start on another port |
74
76
 
75
77
  Settings live in `~/.ava-engine` (set `AVA_HOME` to move them). **Watch the demo locally** at `http://localhost:5190/app?demo=simple`.
76
78
 
77
- **For servers and scripts:** `OPENROUTER_API_KEY=… ACCESS_PASSWORD=… npx ava-engine setup --yes --models english-small` sets everything up without questions. There's also a Docker image; see [DEPLOY.md](DEPLOY.md).
79
+ **On a server:** `npx ava-engine setup --web` works over SSH too. Forward the port (`ssh -L 5190:localhost:5190 your-server`) and open the link it prints. Or skip the questions: `OPENROUTER_API_KEY=… ACCESS_PASSWORD=… npx ava-engine setup --yes --models english-small,kokoro`. There's also a Docker image; see [DEPLOY.md](DEPLOY.md).
78
80
 
79
81
  <details>
80
82
  <summary><b>Working on Ava itself? Run from source</b></summary>
@@ -147,12 +149,14 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
147
149
  **For the customer**
148
150
  - **Talks, naturally.** Live voice with streamed replies, interruptible at any time (barge-in), in text or voice.
149
151
  - **Sees their screen.** Reads the shared tab — the page, what's selected, the numbers on it — and grounds every answer in it. Never invents buttons; if it isn't sure, it takes another look.
150
- - **Helps with anything on the page.** A *Flexible agent* for whatever the customer is doing, and an *Onboarding agent* that walks a journey step by step on a chosen product (Todoist, Supabase, Stripe, AWS Console, Salesforce, Datadog, Vercel).
152
+ - **Helps with anything on the page.** A *Flexible agent* for whatever the customer is doing, and an *Onboarding agent* that walks a journey step by step on your product, described in a JSON [playbook](docs/setup/customize.md) (samples included for Todoist, Supabase, Stripe, AWS Console, Salesforce, Datadog and Vercel).
151
153
  - **Speaks up.** Reacts when the screen changes, and flags risky or costly moves before they happen (a $9,900 seat count, a destructive click).
152
154
  - **Knows when it's done.** Confirms each step actually worked, and goes quiet once the task is complete.
153
155
  - **A person, one tap away.** Hands off to a human specialist with the goal, the screen and the progress already shared — the specialist joins the same call over WebRTC.
154
156
 
155
157
  **For the team running it**
158
+ - **Events** — webhooks for call started, step completed, handoff requested and call ended, plus a Slack alert with a join link when a customer asks for a person.
159
+ - **Spend caps** — per call and per day across all calls.
156
160
  - **Session recap** — an AI summary, highlights and stats, plus a replay of how the agents worked, with a timestamped timeline.
157
161
  - **Console** — end-to-end latency per turn, which agents ran and how long each took, the compiled context the talker saw, and live spend.
158
162
  - **Per-platform theming** — onboarding calls take on the product's brand colour.
package/lib/asr.mjs CHANGED
@@ -76,7 +76,11 @@ function getOffline() {
76
76
  }
77
77
 
78
78
  export function warmAsr() {
79
- if (!asrAvailable()) { console.log(" sherpa ASR: no models found at", MODELS); return; }
79
+ if (!asrAvailable()) {
80
+ if (initErr) console.log(" speech recognition: the offline engine didn't load; using the browser's (run `npx ava-engine doctor`)");
81
+ else console.log(" speech recognition: using the browser's. For offline recognition: npx ava-engine models");
82
+ return;
83
+ }
80
84
  const e = availableEngines();
81
85
  if (e.includes("zipformer") && getOnline()) console.log(" sherpa ASR ready — English streaming (zipformer)");
82
86
  if (e.includes("sensevoice")) console.log(" sherpa ASR ready — multilingual (SenseVoice), lazy-loaded on first use");
@@ -0,0 +1,58 @@
1
+ // Turning setup answers into saved settings. Shared by the terminal wizard and
2
+ // the browser setup, so both produce exactly the same configuration.
3
+ import { MODELS_DIR, DEFAULT_PORT } from "./config.mjs";
4
+
5
+ export const VOICES = {
6
+ kokoro: { label: "Kokoro", hint: "free, runs on this computer (~90 MB download)" },
7
+ elevenlabs: { label: "ElevenLabs", hint: "premium cloud voices, needs an ElevenLabs key" },
8
+ browser: { label: "The browser's voice", hint: "no setup, sounds robotic" },
9
+ };
10
+
11
+ /**
12
+ * answers: {
13
+ * openrouterKey?: string | null (null/"" → MOCK mode; undefined → keep the current key)
14
+ * voice: "kokoro" | "elevenlabs" | "browser",
15
+ * elevenKey?, elevenVoice?,
16
+ * audience: "me" | "others", password?, proxy?: boolean,
17
+ * port?: number
18
+ * }
19
+ */
20
+ export function buildConfig(prev, a) {
21
+ const cfg = { ...prev };
22
+ const set = (k, v) => { if (v === undefined || v === null || v === "") delete cfg[k]; else cfg[k] = String(v); };
23
+
24
+ if (a.openrouterKey !== undefined) set("OPENROUTER_API_KEY", a.openrouterKey);
25
+
26
+ const voice = VOICES[a.voice] ? a.voice : "kokoro";
27
+ set("TTS_DEFAULT", voice);
28
+ set("KOKORO", voice === "kokoro" ? "on" : "off");
29
+ if (voice === "elevenlabs") {
30
+ if (a.elevenKey !== undefined) set("ELEVENLABS_API_KEY", a.elevenKey);
31
+ if (a.elevenVoice !== undefined) set("ELEVEN_VOICE_ID", a.elevenVoice);
32
+ }
33
+
34
+ if (a.audience === "others") {
35
+ set("HOST", "");
36
+ if (a.password !== undefined) set("ACCESS_PASSWORD", a.password);
37
+ set("TRUST_PROXY", a.proxy ? "1" : "");
38
+ } else {
39
+ set("HOST", "127.0.0.1");
40
+ set("ACCESS_PASSWORD", "");
41
+ set("TRUST_PROXY", "");
42
+ }
43
+
44
+ const port = Number(a.port);
45
+ set("PORT", Number.isInteger(port) && port > 0 && port < 65536 ? port : prev.PORT || DEFAULT_PORT);
46
+ set("AVA_MODELS_DIR", MODELS_DIR);
47
+ return cfg;
48
+ }
49
+
50
+ /** Problems that must be fixed before saving, as a list of human messages. */
51
+ export function validateAnswers(a) {
52
+ const errors = [];
53
+ if (a.audience === "others" && (!a.password || String(a.password).length < 10)) errors.push("Choose an access password of at least 10 characters.");
54
+ if (a.voice === "elevenlabs" && a.elevenKey === "") errors.push("Add your ElevenLabs key, or pick another voice.");
55
+ const port = Number(a.port);
56
+ if (a.port !== undefined && !(Number.isInteger(port) && port > 0 && port < 65536)) errors.push("The port must be a number between 1 and 65535.");
57
+ return errors;
58
+ }
package/lib/cli/main.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // The `ava-engine` command.
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join, dirname } from "node:path";
4
+ import { homedir } from "node:os";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { createRequire } from "node:module";
6
7
  import { AVA_HOME, CONFIG_FILE, MODELS_DIR, DEFAULT_PORT, readConfig, checkOpenRouterKey, portFree, openBrowser } from "./config.mjs";
@@ -14,17 +15,21 @@ const HELP = `
14
15
 
15
16
  Usage
16
17
  npx ava-engine first run: setup wizard, then start. After that: start
17
- npx ava-engine setup (re)run the setup wizard
18
+ npx ava-engine setup (re)run setup; asks: in the browser or the terminal
19
+ npx ava-engine setup --web setup in your browser
20
+ npx ava-engine setup --terminal
18
21
  npx ava-engine start start the server
19
22
  npx ava-engine doctor check your setup and tell you what to fix
20
- npx ava-engine models [..] download speech models: ${Object.keys(MODEL_PACKS).join(", ")}
23
+ npx ava-engine models [..] download models: ${Object.keys(MODEL_PACKS).join(", ")}, kokoro (voice)
24
+ npx ava-engine playbooks list and check your product playbooks
25
+ npx ava-engine playbooks new <id> write a starter playbook to fill in
21
26
 
22
27
  Options
23
28
  --port <n> port to listen on (default ${DEFAULT_PORT})
24
29
  --open open the browser once Ava is up (default on a first run)
25
30
  --no-open don't open the browser
26
31
  --yes setup without questions, from environment variables
27
- --models <a,b> with --yes: model packs to download
32
+ --models <a,b> with --yes: what to download, e.g. english-small,kokoro
28
33
  -v, --version print the version
29
34
  -h, --help show this help
30
35
 
@@ -40,6 +45,8 @@ function parseArgs(argv) {
40
45
  else if (a === "-v" || a === "--version") out.flags.version = true;
41
46
  else if (a === "--yes" || a === "-y") out.flags.yes = true;
42
47
  else if (a === "--open") out.flags.open = true;
48
+ else if (a === "--web") out.flags.web = true;
49
+ else if (a === "--terminal") out.flags.terminal = true;
43
50
  else if (a === "--no-open") out.flags.open = false;
44
51
  else if (a === "--port") out.flags.port = Number(argv[++i]);
45
52
  else if (a.startsWith("--port=")) out.flags.port = Number(a.slice(7));
@@ -73,6 +80,35 @@ async function start({ port, open }) {
73
80
  }
74
81
  }
75
82
 
83
+ /** `ava-engine playbooks [new <id>]`: your products as JSON files. */
84
+ async function playbooks(args) {
85
+ const { loadPlaybooks, validatePlaybook, template } = await import("../playbooks.mjs");
86
+ const cfg = readConfig() || {};
87
+ const dir = (process.env.PLAYBOOKS_DIR || cfg.PLAYBOOKS_DIR || join(AVA_HOME, "playbooks")).replace(/^~(?=$|[\\/])/, homedir());
88
+ if (args[0] === "new") {
89
+ const id = args[1];
90
+ if (!id || validatePlaybook({ ...template(id), id }).errors) { console.error("✗ usage: ava-engine playbooks new <id> (lowercase letters, digits and dashes, e.g. acme)"); return 2; }
91
+ const file = join(dir, `${id}.json`);
92
+ if (existsSync(file)) { console.error(`✗ ${file} already exists`); return 1; }
93
+ mkdirSync(dir, { recursive: true });
94
+ writeFileSync(file, JSON.stringify(template(id), null, 2) + "\n");
95
+ console.log(`\n ✓ Wrote ${file}\n\n Fill it in for your product, check it with: npx ava-engine playbooks\n then restart Ava. Field guide: https://github.com/Mr-vero/ava-engine/blob/main/docs/setup/customize.md\n`);
96
+ return 0;
97
+ }
98
+ if (args.length) { console.error(`✗ unknown playbooks command "${args[0]}" (try: ava-engine playbooks new <id>)`); return 2; }
99
+ const { playbooks: ok, problems } = loadPlaybooks(dir);
100
+ console.log(`\n ΛVΛ Playbooks in ${dir}\n`);
101
+ if (!ok.length && !problems.length) console.log(" None yet. Create one with: npx ava-engine playbooks new <id>");
102
+ for (const p of ok) console.log(` ✓ ${p.name} (${p.id}): ${p.journey.length} steps, ${p.knowledge.length} knowledge entries [${p.source}]`);
103
+ for (const p of problems) {
104
+ for (const e of p.errors) console.log(` ✗ ${p.file}: ${e}`);
105
+ for (const w of p.warnings) console.log(` ! ${p.file}: ${w}`);
106
+ }
107
+ const hidden = /^(off|0|false|no)$/i.test(process.env.BUILTIN_PLAYBOOKS || cfg.BUILTIN_PLAYBOOKS || "");
108
+ console.log(`\n Built-in sample products are ${hidden ? "hidden (BUILTIN_PLAYBOOKS=off)" : "shown too. Hide them with BUILTIN_PLAYBOOKS=off in " + CONFIG_FILE}.\n`);
109
+ return problems.some((p) => p.errors.length) ? 1 : 0;
110
+ }
111
+
76
112
  async function doctor() {
77
113
  const cfg = readConfig();
78
114
  const rows = [];
@@ -114,9 +150,10 @@ export async function main(argv) {
114
150
  switch (cmd) {
115
151
  case "help": console.log(HELP); return 0;
116
152
  case "doctor": return doctor();
153
+ case "playbooks": return playbooks(args._.slice(1));
117
154
  case "start": await start({ port: flags.port, open: flags.open === true }); return null; // keeps running
118
155
  case "models": {
119
- const { downloadPacks } = await import("./setup.mjs");
156
+ const { downloadPacks, downloadKokoro } = await import("./setup.mjs");
120
157
  let packs = args._.slice(1);
121
158
  if (!packs.length && interactive) {
122
159
  const p = await import("@clack/prompts");
@@ -126,19 +163,50 @@ export async function main(argv) {
126
163
  packs = sel;
127
164
  }
128
165
  if (!packs.length) packs = ["english-small"];
129
- const bad = packs.filter((x) => !MODEL_PACKS[x]);
130
- if (bad.length) { console.error(`✗ unknown model pack: ${bad.join(", ")}. Choose from: ${Object.keys(MODEL_PACKS).join(", ")}`); return 2; }
131
- await downloadPacks(packs);
166
+ const bad = packs.filter((x) => !MODEL_PACKS[x] && x !== "kokoro");
167
+ if (bad.length) { console.error(`✗ unknown model: ${bad.join(", ")}. Choose from: ${Object.keys(MODEL_PACKS).join(", ")}, kokoro`); return 2; }
168
+ if (packs.includes("kokoro")) await downloadKokoro();
169
+ await downloadPacks(packs.filter((x) => x !== "kokoro"));
132
170
  return 0;
133
171
  }
134
172
  case "setup":
135
173
  case "first-run": {
136
174
  const setup = await import("./setup.mjs");
137
- if (!interactive) {
175
+ // --web needs a browser, not a terminal, so it works over SSH and from scripts too.
176
+ if (!interactive && !(flags.web && !flags.yes)) {
138
177
  await setup.runSetupNonInteractive({ models: flags.models || [], port: flags.port });
139
178
  if (cmd === "first-run") { await start({ port: flags.port, open: flags.open === true }); return null; }
140
179
  return 0;
141
180
  }
181
+ // Where to run the wizard: the browser (default) or this terminal.
182
+ let where = flags.web ? "web" : flags.terminal ? "terminal" : null;
183
+ if (!where) {
184
+ const p = await import("@clack/prompts");
185
+ console.log("");
186
+ p.intro(" ΛVΛ Ava Engine setup ");
187
+ where = await p.select({
188
+ message: "Where would you like to set up Ava?",
189
+ options: [
190
+ { value: "web", label: "In my browser", hint: "recommended; a step-by-step page" },
191
+ { value: "terminal", label: "Here in the terminal" },
192
+ ],
193
+ });
194
+ if (p.isCancel(where)) { p.cancel("Setup cancelled. Nothing was changed."); return 0; }
195
+ }
196
+ if (where === "web") {
197
+ const { runWebSetup } = await import("./setup-server.mjs");
198
+ const r = await runWebSetup({
199
+ preferredPort: flags.port,
200
+ formPort: flags.port,
201
+ onReady: ({ url }) => {
202
+ console.log(`\n Setup is open in your browser. If it didn't open, go to:\n ${url}\n\n (Keep this terminal open. Press Ctrl+C to cancel.)\n`);
203
+ if (flags.open !== false) openBrowser(url);
204
+ },
205
+ });
206
+ console.log(" Settings saved. Starting Ava…");
207
+ await start({ port: r.port, open: false }); // the port chosen on the page wins; the page redirects itself
208
+ return null;
209
+ }
142
210
  const r = await setup.runSetup();
143
211
  if (r.start) { await start({ port: flags.port || r.port, open: flags.open !== false }); return null; }
144
212
  return 0;
@@ -0,0 +1,87 @@
1
+ // "Connect OpenRouter": get an API key by signing in, instead of copying one.
2
+ //
3
+ // OpenRouter's OAuth PKCE flow (https://openrouter.ai/docs/use-cases/oauth-pkce):
4
+ // 1. open openrouter.ai/auth with a localhost callback and a S256 code challenge,
5
+ // 2. the person approves; OpenRouter redirects to the callback with ?code=…,
6
+ // 3. we trade the code (plus the verifier only we know) for a key.
7
+ // The key is billed to their own OpenRouter credit. Codes expire after 10 minutes.
8
+
9
+ import http from "node:http";
10
+ import { createHash, randomBytes } from "node:crypto";
11
+
12
+ // Overridable so tests can stand in for openrouter.ai; never set this in real use.
13
+ const BASE = () => (process.env.AVA_OPENROUTER_BASE || "https://openrouter.ai").replace(/\/+$/, "");
14
+ export const FLOW_TIMEOUT_MS = 10 * 60_000;
15
+
16
+ /** A fresh PKCE pair and CSRF state. */
17
+ export function pkce() {
18
+ const verifier = randomBytes(32).toString("base64url");
19
+ return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url"), state: randomBytes(16).toString("base64url") };
20
+ }
21
+
22
+ export function authUrl({ callbackUrl, challenge, state }) {
23
+ const q = new URLSearchParams({ callback_url: callbackUrl, code_challenge: challenge, code_challenge_method: "S256", state, key_label: "Ava Engine" });
24
+ return `${BASE()}/auth?${q}`;
25
+ }
26
+
27
+ /** Trade the callback's code for an API key. Throws an Error with a message fit to show. */
28
+ export async function exchangeCode({ code, verifier }) {
29
+ let r;
30
+ try {
31
+ r = await fetch(`${BASE()}/api/v1/auth/keys`, {
32
+ method: "POST", headers: { "Content-Type": "application/json" },
33
+ body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: "S256" }),
34
+ signal: AbortSignal.timeout(15_000),
35
+ });
36
+ } catch { throw new Error("Couldn't reach OpenRouter. Check your connection and try again."); }
37
+ // The docs say 403 for a bad or expired code; the live API answers 400.
38
+ if (r.status === 400 || r.status === 403) throw new Error("OpenRouter didn't accept the sign-in (it may have expired). Try connecting again.");
39
+ if (!r.ok) throw new Error(`OpenRouter answered ${r.status}. Try again, or paste a key instead.`);
40
+ const j = await r.json().catch(() => ({}));
41
+ if (typeof j.key !== "string" || j.key.length < 20) throw new Error("OpenRouter didn't return a key. Try again, or paste a key instead.");
42
+ return j.key;
43
+ }
44
+
45
+ /** The page the browser lands on after approving. Fixed text only: nothing from the request is echoed. */
46
+ export function callbackPage(ok, { cssHref } = {}) {
47
+ const [title, text] = ok
48
+ ? ["OpenRouter is connected", "You can close this tab and go back to setup."]
49
+ : ["Not connected", "OpenRouter didn't connect. Close this tab and try again, or paste a key instead."];
50
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title} · Ava setup</title>${cssHref ? `<link rel="stylesheet" href="${cssHref}">` : ""}</head>`
51
+ + `<body><main class="panel launching"><h1>${ok ? "✓ " : ""}${title}</h1><p class="lead">${text}</p></main></body></html>`;
52
+ }
53
+
54
+ /**
55
+ * Terminal flow: listen on 127.0.0.1, open the browser, wait for the callback.
56
+ * Resolves the key, or rejects (timeout, refused, bad state).
57
+ * onUrl(url) is told the sign-in URL (open it and print it).
58
+ */
59
+ export function connectInBrowser({ onUrl, timeoutMs = FLOW_TIMEOUT_MS } = {}) {
60
+ const { verifier, challenge, state } = pkce();
61
+ return new Promise((resolve, reject) => {
62
+ let done = false;
63
+ const finish = (err, key) => {
64
+ if (done) return; done = true;
65
+ clearTimeout(timer);
66
+ setTimeout(() => server.close(), 200).unref(); // let the callback page finish sending
67
+ server.closeAllConnections?.();
68
+ if (err) reject(err); else resolve(key);
69
+ };
70
+ const server = http.createServer(async (req, res) => {
71
+ const url = new URL(req.url, "http://localhost");
72
+ if (url.pathname !== "/callback") { res.writeHead(404); return res.end(); }
73
+ const page = (ok) => { res.writeHead(ok ? 200 : 400, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "Content-Security-Policy": "default-src 'none'" }); res.end(callbackPage(ok)); };
74
+ const code = url.searchParams.get("code");
75
+ if (!code) { page(false); return finish(new Error("The sign-in was cancelled.")); }
76
+ if (url.searchParams.get("state") !== state) { page(false); return; } // not ours: keep waiting for the real one
77
+ try { const key = await exchangeCode({ code, verifier }); page(true); finish(null, key); }
78
+ catch (e) { page(false); finish(e); }
79
+ });
80
+ const timer = setTimeout(() => finish(new Error("Timed out waiting for the OpenRouter sign-in.")), timeoutMs);
81
+ timer.unref?.();
82
+ server.on("error", (e) => finish(e));
83
+ server.listen(0, "127.0.0.1", () => {
84
+ onUrl?.(authUrl({ callbackUrl: `http://localhost:${server.address().port}/callback`, challenge, state }));
85
+ });
86
+ });
87
+ }
@@ -0,0 +1,222 @@
1
+ // `ava-engine setup --web`: the setup wizard as a page in your browser.
2
+ //
3
+ // A small, separate server that runs only during setup:
4
+ // - listens on 127.0.0.1 only,
5
+ // - every API call needs the one-time token from the URL the CLI opens,
6
+ // - rejects requests whose Host isn't localhost (stops DNS-rebinding attacks),
7
+ // - serves a strict Content-Security-Policy.
8
+ // When you press "Launch", it saves the settings, closes, and the CLI starts Ava.
9
+
10
+ import http from "node:http";
11
+ import { readFile } from "node:fs/promises";
12
+ import { join, dirname } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { randomBytes, timingSafeEqual } from "node:crypto";
15
+ import { CONFIG_FILE, MODELS_DIR, DEFAULT_PORT, readConfig, writeConfig, checkOpenRouterKey, checkElevenLabsKey, portFree, suggestPassword } from "./config.mjs";
16
+ import { buildConfig, validateAnswers, VOICES } from "./apply.mjs";
17
+ import { MODEL_PACKS, installedPacks, fetchPack } from "../fetch-models.mjs";
18
+ import { loadKokoro, kokoroInstalled, KOKORO_SIZE } from "../kokoro-model.mjs";
19
+ import { pkce, authUrl, exchangeCode, callbackPage, FLOW_TIMEOUT_MS } from "./openrouter-oauth.mjs";
20
+
21
+ const PUBLIC = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "public");
22
+ const STATIC = new Map([
23
+ ["/setup", ["setup.html", "text/html; charset=utf-8"]],
24
+ ["/setup.js", ["setup.js", "text/javascript; charset=utf-8"]],
25
+ ["/setup.css", ["setup.css", "text/css; charset=utf-8"]],
26
+ ["/favicon.svg", ["favicon.svg", "image/svg+xml"]],
27
+ ["/fonts/inter-var.woff2", ["fonts/inter-var.woff2", "font/woff2"]],
28
+ ["/fonts/inter-tight-var.woff2", ["fonts/inter-tight-var.woff2", "font/woff2"]],
29
+ ["/fonts/plex-mono-400.woff2", ["fonts/plex-mono-400.woff2", "font/woff2"]],
30
+ ["/fonts/plex-mono-500.woff2", ["fonts/plex-mono-500.woff2", "font/woff2"]],
31
+ ]);
32
+ const HEADERS = {
33
+ "X-Content-Type-Options": "nosniff",
34
+ "Referrer-Policy": "no-referrer",
35
+ "Cache-Control": "no-store",
36
+ "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:*; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
37
+ };
38
+
39
+ const mask = (v) => (v ? `${v.slice(0, 6)}…${v.slice(-4)}` : "");
40
+
41
+ async function findPort(preferred) {
42
+ for (let p = preferred; p < preferred + 20; p++) if (await portFree(p, "127.0.0.1")) return p;
43
+ return 0; // let the OS pick
44
+ }
45
+
46
+ /**
47
+ * Run the browser setup. Resolves { start: true, port } once you press Launch.
48
+ * onReady({ url }) is called when the page is ready to open.
49
+ */
50
+ export async function runWebSetup({ preferredPort, formPort, onReady } = {}) {
51
+ process.env.AVA_MODELS_DIR ??= MODELS_DIR; // so Kokoro downloads land in ~/.ava-engine/models
52
+ const prev = readConfig() || {};
53
+ const token = randomBytes(24).toString("base64url");
54
+ const jobs = {}; // id → { label, state: "running" | "done" | "error", pct, error }
55
+ // "Connect OpenRouter": one sign-in at a time. The key stays here; the page only sees it masked.
56
+ let oauth = null; // { verifier, state, startedAt, status: "waiting" | "connected" | "error", key, error }
57
+
58
+ const okToken = (req) => {
59
+ const t = Buffer.from(String(req.headers["x-setup-token"] || ""));
60
+ const want = Buffer.from(token);
61
+ return t.length === want.length && timingSafeEqual(t, want);
62
+ };
63
+
64
+ function startJob(id) {
65
+ if (jobs[id]?.state === "running" || jobs[id]?.state === "done") return;
66
+ if (id === "kokoro") {
67
+ jobs[id] = { label: `Kokoro voice (${KOKORO_SIZE})`, state: "running", pct: 0 };
68
+ if (kokoroInstalled()) { jobs[id] = { ...jobs[id], state: "done", pct: 100 }; return; }
69
+ loadKokoro({ onProgress: ({ pct }) => { jobs[id].pct = pct; } })
70
+ .then(() => { jobs[id].state = "done"; jobs[id].pct = 100; })
71
+ .catch((e) => { jobs[id].state = "error"; jobs[id].error = e.message; });
72
+ return;
73
+ }
74
+ const spec = MODEL_PACKS[id];
75
+ jobs[id] = { label: `${spec.label} (${spec.size})`, state: "running", pct: 0 };
76
+ fetchPack(id, MODELS_DIR, (ev) => {
77
+ if (ev.phase === "download" && ev.total) jobs[id].pct = Math.min(99, Math.floor((ev.done / ev.total) * 100));
78
+ if (ev.phase === "extract") jobs[id].phase = "unpacking";
79
+ }).then(() => { jobs[id].state = "done"; jobs[id].pct = 100; })
80
+ .catch((e) => { jobs[id].state = "error"; jobs[id].error = e.message; });
81
+ }
82
+
83
+ function state() {
84
+ const c = readConfig() || prev;
85
+ return {
86
+ configFile: CONFIG_FILE,
87
+ current: {
88
+ openrouterKey: mask(c.OPENROUTER_API_KEY),
89
+ voice: VOICES[c.TTS_DEFAULT] ? c.TTS_DEFAULT : "kokoro",
90
+ elevenKey: mask(c.ELEVENLABS_API_KEY),
91
+ elevenVoice: c.ELEVEN_VOICE_ID || "",
92
+ audience: c.ACCESS_PASSWORD ? "others" : "me",
93
+ hasPassword: !!c.ACCESS_PASSWORD,
94
+ proxy: c.TRUST_PROXY === "1",
95
+ port: formPort || Number(c.PORT) || DEFAULT_PORT,
96
+ },
97
+ voices: VOICES,
98
+ kokoro: { installed: kokoroInstalled(), size: KOKORO_SIZE },
99
+ packs: Object.fromEntries(Object.entries(MODEL_PACKS).map(([id, m]) => [id, { label: m.label, size: m.size, hint: m.hint }])),
100
+ installedPacks: installedPacks(MODELS_DIR),
101
+ suggestedPassword: suggestPassword(),
102
+ jobs,
103
+ };
104
+ }
105
+
106
+ let finish;
107
+ const finished = new Promise((r) => { finish = r; });
108
+ let listenPort = 0;
109
+
110
+ const server = http.createServer(async (req, res) => {
111
+ const send = (code, body, type = "application/json") => {
112
+ res.writeHead(code, { ...HEADERS, "Content-Type": type });
113
+ res.end(typeof body === "string" || Buffer.isBuffer(body) ? body : JSON.stringify(body));
114
+ };
115
+ // DNS-rebinding guard: only answer requests addressed to this machine.
116
+ const host = String(req.headers.host || "");
117
+ if (!/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(host)) return send(403, { error: "forbidden host" });
118
+ let url;
119
+ try { url = new URL(req.url, "http://localhost"); } catch { return send(400, { error: "bad request" }); }
120
+
121
+ if (req.method === "GET" && STATIC.has(url.pathname)) {
122
+ const [file, type] = STATIC.get(url.pathname);
123
+ try { return send(200, await readFile(join(PUBLIC, file)), type); } catch { return send(404, "not found", "text/plain"); }
124
+ }
125
+ if (req.method === "GET" && url.pathname === "/") { res.writeHead(302, { Location: "/setup" }); return res.end(); }
126
+ // OpenRouter redirects the browser here after sign-in. It can't carry the page's
127
+ // token, so the one-time state from /openrouter/start proves it's ours.
128
+ if (req.method === "GET" && url.pathname === "/openrouter/callback") {
129
+ const page = (ok) => send(ok ? 200 : 400, callbackPage(ok, { cssHref: "/setup.css" }), "text/html; charset=utf-8");
130
+ const f = oauth;
131
+ if (!f || f.status !== "waiting" || Date.now() - f.startedAt > FLOW_TIMEOUT_MS) return page(false);
132
+ const code = url.searchParams.get("code");
133
+ if (!code) { f.status = "error"; f.error = "The sign-in was cancelled."; return page(false); }
134
+ const st = Buffer.from(String(url.searchParams.get("state") || "")), want = Buffer.from(f.state);
135
+ if (!(st.length === want.length && timingSafeEqual(st, want))) return page(false);
136
+ f.status = "exchanging";
137
+ try { f.key = await exchangeCode({ code, verifier: f.verifier }); f.status = "connected"; return page(true); }
138
+ catch (e) { f.status = "error"; f.error = e.message; return page(false); }
139
+ }
140
+ if (!url.pathname.startsWith("/api/setup/")) return send(404, "not found", "text/plain");
141
+ if (!okToken(req)) return send(401, { error: "This setup link has expired. Run `npx ava-engine setup --web` for a new one." });
142
+
143
+ let body = {};
144
+ if (req.method === "POST") {
145
+ if (!/^application\/json\b/i.test(String(req.headers["content-type"] || ""))) return send(415, { error: "expected application/json" });
146
+ const chunks = []; let size = 0;
147
+ for await (const c of req) { size += c.length; if (size > 64 * 1024) return send(413, { error: "too large" }); chunks.push(c); }
148
+ try { body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); } catch { return send(400, { error: "invalid JSON" }); }
149
+ }
150
+
151
+ try {
152
+ const route = `${req.method} ${url.pathname}`;
153
+ if (route === "GET /api/setup/state") return send(200, state());
154
+ if (route === "GET /api/setup/jobs") return send(200, { jobs, installedPacks: installedPacks(MODELS_DIR), kokoroInstalled: kokoroInstalled() });
155
+ if (route === "GET /api/setup/password") return send(200, { password: suggestPassword() });
156
+ if (route === "POST /api/setup/check") {
157
+ const key = String(body.key || "").trim();
158
+ if (key.length < 20) return send(200, { ok: false, error: "That doesn't look like a full key." });
159
+ const r = body.service === "elevenlabs" ? await checkElevenLabsKey(key) : await checkOpenRouterKey(key);
160
+ if (r.ok && r.label && /^sk-or/i.test(r.label)) delete r.label; // unnamed keys report a masked key as their label
161
+ return send(200, r);
162
+ }
163
+ if (route === "POST /api/setup/openrouter/start") {
164
+ const { verifier, challenge, state: st } = pkce();
165
+ oauth = { verifier, state: st, startedAt: Date.now(), status: "waiting" };
166
+ return send(200, { url: authUrl({ callbackUrl: `http://localhost:${listenPort}/openrouter/callback`, challenge, state: st }) });
167
+ }
168
+ if (route === "GET /api/setup/openrouter/status") {
169
+ if (oauth?.status === "waiting" && Date.now() - oauth.startedAt > FLOW_TIMEOUT_MS) { oauth.status = "error"; oauth.error = "The sign-in timed out. Try again."; }
170
+ return send(200, oauth ? { status: oauth.status, key: oauth.key ? mask(oauth.key) : undefined, error: oauth.error } : { status: "none" });
171
+ }
172
+ if (route === "POST /api/setup/download") {
173
+ const id = String(body.id || "");
174
+ if (id !== "kokoro" && !MODEL_PACKS[id]) return send(400, { error: "unknown download" });
175
+ startJob(id);
176
+ return send(200, { ok: true, job: jobs[id] });
177
+ }
178
+ if (route === "POST /api/setup/save") {
179
+ const a = body.answers || {};
180
+ // "keep" means: leave the saved key as it is; "connected": the key from Connect OpenRouter.
181
+ if (a.mode !== "mock" && a.openrouterKey === "connected" && oauth?.status !== "connected") return send(400, { error: "Connect OpenRouter again: the sign-in didn't finish." });
182
+ const answers = {
183
+ openrouterKey: a.mode === "mock" ? null : a.openrouterKey === "keep" ? undefined : a.openrouterKey === "connected" ? oauth.key : String(a.openrouterKey || "") || null,
184
+ voice: a.voice,
185
+ elevenKey: a.elevenKey === "keep" ? undefined : a.elevenKey,
186
+ elevenVoice: a.elevenVoice,
187
+ audience: a.audience === "others" ? "others" : "me",
188
+ password: a.audience === "others" ? (a.password === "keep" ? prev.ACCESS_PASSWORD || readConfig()?.ACCESS_PASSWORD : a.password) : undefined,
189
+ proxy: !!a.proxy,
190
+ port: a.port,
191
+ };
192
+ const errors = validateAnswers(answers);
193
+ if (errors.length) return send(400, { error: errors.join(" ") });
194
+ const cfg = buildConfig(readConfig() || prev, answers);
195
+ const p = Number(cfg.PORT);
196
+ if (p !== listenPort && !(await portFree(p, cfg.HOST))) return send(409, { error: `Port ${p} is in use by another program. Pick another port.` });
197
+ writeConfig(cfg);
198
+ return send(200, { ok: true, port: p, url: `http://localhost:${p}/app`, configFile: CONFIG_FILE });
199
+ }
200
+ if (route === "POST /api/setup/finish") {
201
+ const cfg = readConfig();
202
+ if (!cfg) return send(400, { error: "Save your settings first." });
203
+ // Downloads still running (e.g. a voice you switched away from) keep going in
204
+ // this process after Ava starts; the page already waited for the ones you chose.
205
+ send(200, { ok: true, port: Number(cfg.PORT) || DEFAULT_PORT });
206
+ // let the response flush, then hand the port over to Ava
207
+ setTimeout(() => { server.closeAllConnections?.(); server.close(() => finish({ start: true, port: Number(cfg.PORT) || DEFAULT_PORT })); }, 150);
208
+ return;
209
+ }
210
+ return send(404, { error: "unknown endpoint" });
211
+ } catch (e) {
212
+ return send(500, { error: e.message });
213
+ }
214
+ });
215
+
216
+ listenPort = await findPort(preferredPort || Number(prev.PORT) || DEFAULT_PORT);
217
+ await new Promise((r) => server.listen(listenPort, "127.0.0.1", r));
218
+ listenPort = server.address().port;
219
+ const url = `http://localhost:${listenPort}/setup#token=${token}`;
220
+ onReady?.({ url, port: listenPort });
221
+ return finished;
222
+ }