ava-engine 0.0.0-stage → 0.2.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.
Files changed (104) hide show
  1. package/.env.example +63 -0
  2. package/CLA.md +43 -0
  3. package/COMMERCIAL-LICENSE.md +43 -0
  4. package/LICENSE +73 -0
  5. package/README.md +240 -2
  6. package/THIRD_PARTY_NOTICES.md +38 -0
  7. package/TRADEMARKS.md +31 -0
  8. package/bin/ava-engine.mjs +12 -0
  9. package/lib/asr.mjs +147 -0
  10. package/lib/cli/apply.mjs +58 -0
  11. package/lib/cli/config.mjs +95 -0
  12. package/lib/cli/main.mjs +187 -0
  13. package/lib/cli/setup-server.mjs +195 -0
  14. package/lib/cli/setup.mjs +233 -0
  15. package/lib/fetch-models.mjs +104 -0
  16. package/lib/guard.mjs +92 -0
  17. package/lib/kokoro-model.mjs +51 -0
  18. package/lib/models.mjs +47 -0
  19. package/lib/openrouter.mjs +153 -0
  20. package/lib/ours.mjs +766 -0
  21. package/lib/platforms.mjs +201 -0
  22. package/lib/state.mjs +150 -0
  23. package/lib/stt.mjs +22 -0
  24. package/lib/tts.mjs +178 -0
  25. package/lib/workflow.mjs +18 -0
  26. package/package.json +80 -4
  27. package/public/app.js +1708 -0
  28. package/public/demo.js +420 -0
  29. package/public/favicon.svg +1 -0
  30. package/public/fonts/OFL.txt +96 -0
  31. package/public/fonts/inter-tight-var.woff2 +0 -0
  32. package/public/fonts/inter-var.woff2 +0 -0
  33. package/public/fonts/plex-mono-400.woff2 +0 -0
  34. package/public/fonts/plex-mono-500.woff2 +0 -0
  35. package/public/img/cta-1600.avif +0 -0
  36. package/public/img/cta-1600.webp +0 -0
  37. package/public/img/cta-2400.avif +0 -0
  38. package/public/img/cta-2400.webp +0 -0
  39. package/public/img/cta-960.avif +0 -0
  40. package/public/img/cta-960.webp +0 -0
  41. package/public/img/flows-1600.avif +0 -0
  42. package/public/img/flows-1600.webp +0 -0
  43. package/public/img/flows-2400.avif +0 -0
  44. package/public/img/flows-2400.webp +0 -0
  45. package/public/img/flows-960.avif +0 -0
  46. package/public/img/flows-960.webp +0 -0
  47. package/public/img/hero-1600.avif +0 -0
  48. package/public/img/hero-1600.webp +0 -0
  49. package/public/img/hero-2400.avif +0 -0
  50. package/public/img/hero-2400.webp +0 -0
  51. package/public/img/hero-960.avif +0 -0
  52. package/public/img/hero-960.webp +0 -0
  53. package/public/img/p-csm-480.avif +0 -0
  54. package/public/img/p-csm-480.webp +0 -0
  55. package/public/img/p-csm-800.avif +0 -0
  56. package/public/img/p-csm-800.webp +0 -0
  57. package/public/img/p-docs-480.avif +0 -0
  58. package/public/img/p-docs-480.webp +0 -0
  59. package/public/img/p-docs-800.avif +0 -0
  60. package/public/img/p-docs-800.webp +0 -0
  61. package/public/img/p-quiet-480.avif +0 -0
  62. package/public/img/p-quiet-480.webp +0 -0
  63. package/public/img/p-quiet-800.avif +0 -0
  64. package/public/img/p-quiet-800.webp +0 -0
  65. package/public/img/p-tickets-480.avif +0 -0
  66. package/public/img/p-tickets-480.webp +0 -0
  67. package/public/img/p-tickets-800.avif +0 -0
  68. package/public/img/p-tickets-800.webp +0 -0
  69. package/public/img/p-trial-480.avif +0 -0
  70. package/public/img/p-trial-480.webp +0 -0
  71. package/public/img/p-trial-800.avif +0 -0
  72. package/public/img/p-trial-800.webp +0 -0
  73. package/public/img/sees-1600.avif +0 -0
  74. package/public/img/sees-1600.webp +0 -0
  75. package/public/img/sees-2400.avif +0 -0
  76. package/public/img/sees-2400.webp +0 -0
  77. package/public/img/sees-960.avif +0 -0
  78. package/public/img/sees-960.webp +0 -0
  79. package/public/img/speaks-1600.avif +0 -0
  80. package/public/img/speaks-1600.webp +0 -0
  81. package/public/img/speaks-2400.avif +0 -0
  82. package/public/img/speaks-2400.webp +0 -0
  83. package/public/img/speaks-960.avif +0 -0
  84. package/public/img/speaks-960.webp +0 -0
  85. package/public/img/voice-1600.avif +0 -0
  86. package/public/img/voice-1600.webp +0 -0
  87. package/public/img/voice-2400.avif +0 -0
  88. package/public/img/voice-2400.webp +0 -0
  89. package/public/img/voice-960.avif +0 -0
  90. package/public/img/voice-960.webp +0 -0
  91. package/public/index.html +440 -0
  92. package/public/landing.html +975 -0
  93. package/public/pcm-worklet.js +11 -0
  94. package/public/perceptionWorker.js +103 -0
  95. package/public/rtc.js +60 -0
  96. package/public/setup.css +94 -0
  97. package/public/setup.html +129 -0
  98. package/public/setup.js +264 -0
  99. package/public/specialist.html +92 -0
  100. package/public/specialist.js +132 -0
  101. package/public/style.css +836 -0
  102. package/public/vendor/lenis.min.js +3 -0
  103. package/scripts/fetch-models.sh +15 -0
  104. package/server.mjs +502 -0
@@ -0,0 +1,195 @@
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
+
20
+ const PUBLIC = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "public");
21
+ const STATIC = new Map([
22
+ ["/setup", ["setup.html", "text/html; charset=utf-8"]],
23
+ ["/setup.js", ["setup.js", "text/javascript; charset=utf-8"]],
24
+ ["/setup.css", ["setup.css", "text/css; charset=utf-8"]],
25
+ ["/favicon.svg", ["favicon.svg", "image/svg+xml"]],
26
+ ["/fonts/inter-var.woff2", ["fonts/inter-var.woff2", "font/woff2"]],
27
+ ["/fonts/inter-tight-var.woff2", ["fonts/inter-tight-var.woff2", "font/woff2"]],
28
+ ["/fonts/plex-mono-400.woff2", ["fonts/plex-mono-400.woff2", "font/woff2"]],
29
+ ["/fonts/plex-mono-500.woff2", ["fonts/plex-mono-500.woff2", "font/woff2"]],
30
+ ]);
31
+ const HEADERS = {
32
+ "X-Content-Type-Options": "nosniff",
33
+ "Referrer-Policy": "no-referrer",
34
+ "Cache-Control": "no-store",
35
+ "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'",
36
+ };
37
+
38
+ const mask = (v) => (v ? `${v.slice(0, 6)}…${v.slice(-4)}` : "");
39
+
40
+ async function findPort(preferred) {
41
+ for (let p = preferred; p < preferred + 20; p++) if (await portFree(p, "127.0.0.1")) return p;
42
+ return 0; // let the OS pick
43
+ }
44
+
45
+ /**
46
+ * Run the browser setup. Resolves { start: true, port } once you press Launch.
47
+ * onReady({ url }) is called when the page is ready to open.
48
+ */
49
+ export async function runWebSetup({ preferredPort, formPort, onReady } = {}) {
50
+ process.env.AVA_MODELS_DIR ??= MODELS_DIR; // so Kokoro downloads land in ~/.ava-engine/models
51
+ const prev = readConfig() || {};
52
+ const token = randomBytes(24).toString("base64url");
53
+ const jobs = {}; // id → { label, state: "running" | "done" | "error", pct, error }
54
+
55
+ const okToken = (req) => {
56
+ const t = Buffer.from(String(req.headers["x-setup-token"] || ""));
57
+ const want = Buffer.from(token);
58
+ return t.length === want.length && timingSafeEqual(t, want);
59
+ };
60
+
61
+ function startJob(id) {
62
+ if (jobs[id]?.state === "running" || jobs[id]?.state === "done") return;
63
+ if (id === "kokoro") {
64
+ jobs[id] = { label: `Kokoro voice (${KOKORO_SIZE})`, state: "running", pct: 0 };
65
+ if (kokoroInstalled()) { jobs[id] = { ...jobs[id], state: "done", pct: 100 }; return; }
66
+ loadKokoro({ onProgress: ({ pct }) => { jobs[id].pct = pct; } })
67
+ .then(() => { jobs[id].state = "done"; jobs[id].pct = 100; })
68
+ .catch((e) => { jobs[id].state = "error"; jobs[id].error = e.message; });
69
+ return;
70
+ }
71
+ const spec = MODEL_PACKS[id];
72
+ jobs[id] = { label: `${spec.label} (${spec.size})`, state: "running", pct: 0 };
73
+ fetchPack(id, MODELS_DIR, (ev) => {
74
+ if (ev.phase === "download" && ev.total) jobs[id].pct = Math.min(99, Math.floor((ev.done / ev.total) * 100));
75
+ if (ev.phase === "extract") jobs[id].phase = "unpacking";
76
+ }).then(() => { jobs[id].state = "done"; jobs[id].pct = 100; })
77
+ .catch((e) => { jobs[id].state = "error"; jobs[id].error = e.message; });
78
+ }
79
+
80
+ function state() {
81
+ const c = readConfig() || prev;
82
+ return {
83
+ configFile: CONFIG_FILE,
84
+ current: {
85
+ openrouterKey: mask(c.OPENROUTER_API_KEY),
86
+ voice: VOICES[c.TTS_DEFAULT] ? c.TTS_DEFAULT : "kokoro",
87
+ elevenKey: mask(c.ELEVENLABS_API_KEY),
88
+ elevenVoice: c.ELEVEN_VOICE_ID || "",
89
+ audience: c.ACCESS_PASSWORD ? "others" : "me",
90
+ hasPassword: !!c.ACCESS_PASSWORD,
91
+ proxy: c.TRUST_PROXY === "1",
92
+ port: formPort || Number(c.PORT) || DEFAULT_PORT,
93
+ },
94
+ voices: VOICES,
95
+ kokoro: { installed: kokoroInstalled(), size: KOKORO_SIZE },
96
+ packs: Object.fromEntries(Object.entries(MODEL_PACKS).map(([id, m]) => [id, { label: m.label, size: m.size, hint: m.hint }])),
97
+ installedPacks: installedPacks(MODELS_DIR),
98
+ suggestedPassword: suggestPassword(),
99
+ jobs,
100
+ };
101
+ }
102
+
103
+ let finish;
104
+ const finished = new Promise((r) => { finish = r; });
105
+ let listenPort = 0;
106
+
107
+ const server = http.createServer(async (req, res) => {
108
+ const send = (code, body, type = "application/json") => {
109
+ res.writeHead(code, { ...HEADERS, "Content-Type": type });
110
+ res.end(typeof body === "string" || Buffer.isBuffer(body) ? body : JSON.stringify(body));
111
+ };
112
+ // DNS-rebinding guard: only answer requests addressed to this machine.
113
+ const host = String(req.headers.host || "");
114
+ if (!/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(host)) return send(403, { error: "forbidden host" });
115
+ let url;
116
+ try { url = new URL(req.url, "http://localhost"); } catch { return send(400, { error: "bad request" }); }
117
+
118
+ if (req.method === "GET" && STATIC.has(url.pathname)) {
119
+ const [file, type] = STATIC.get(url.pathname);
120
+ try { return send(200, await readFile(join(PUBLIC, file)), type); } catch { return send(404, "not found", "text/plain"); }
121
+ }
122
+ if (req.method === "GET" && url.pathname === "/") { res.writeHead(302, { Location: "/setup" }); return res.end(); }
123
+ if (!url.pathname.startsWith("/api/setup/")) return send(404, "not found", "text/plain");
124
+ if (!okToken(req)) return send(401, { error: "This setup link has expired. Run `npx ava-engine setup --web` for a new one." });
125
+
126
+ let body = {};
127
+ if (req.method === "POST") {
128
+ if (!/^application\/json\b/i.test(String(req.headers["content-type"] || ""))) return send(415, { error: "expected application/json" });
129
+ const chunks = []; let size = 0;
130
+ for await (const c of req) { size += c.length; if (size > 64 * 1024) return send(413, { error: "too large" }); chunks.push(c); }
131
+ try { body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); } catch { return send(400, { error: "invalid JSON" }); }
132
+ }
133
+
134
+ try {
135
+ const route = `${req.method} ${url.pathname}`;
136
+ if (route === "GET /api/setup/state") return send(200, state());
137
+ if (route === "GET /api/setup/jobs") return send(200, { jobs, installedPacks: installedPacks(MODELS_DIR), kokoroInstalled: kokoroInstalled() });
138
+ if (route === "GET /api/setup/password") return send(200, { password: suggestPassword() });
139
+ if (route === "POST /api/setup/check") {
140
+ const key = String(body.key || "").trim();
141
+ if (key.length < 20) return send(200, { ok: false, error: "That doesn't look like a full key." });
142
+ const r = body.service === "elevenlabs" ? await checkElevenLabsKey(key) : await checkOpenRouterKey(key);
143
+ if (r.ok && r.label && /^sk-or/i.test(r.label)) delete r.label; // unnamed keys report a masked key as their label
144
+ return send(200, r);
145
+ }
146
+ if (route === "POST /api/setup/download") {
147
+ const id = String(body.id || "");
148
+ if (id !== "kokoro" && !MODEL_PACKS[id]) return send(400, { error: "unknown download" });
149
+ startJob(id);
150
+ return send(200, { ok: true, job: jobs[id] });
151
+ }
152
+ if (route === "POST /api/setup/save") {
153
+ const a = body.answers || {};
154
+ // "keep" means: leave the saved key as it is.
155
+ const answers = {
156
+ openrouterKey: a.mode === "mock" ? null : a.openrouterKey === "keep" ? undefined : String(a.openrouterKey || "") || null,
157
+ voice: a.voice,
158
+ elevenKey: a.elevenKey === "keep" ? undefined : a.elevenKey,
159
+ elevenVoice: a.elevenVoice,
160
+ audience: a.audience === "others" ? "others" : "me",
161
+ password: a.audience === "others" ? (a.password === "keep" ? prev.ACCESS_PASSWORD || readConfig()?.ACCESS_PASSWORD : a.password) : undefined,
162
+ proxy: !!a.proxy,
163
+ port: a.port,
164
+ };
165
+ const errors = validateAnswers(answers);
166
+ if (errors.length) return send(400, { error: errors.join(" ") });
167
+ const cfg = buildConfig(readConfig() || prev, answers);
168
+ const p = Number(cfg.PORT);
169
+ if (p !== listenPort && !(await portFree(p, cfg.HOST))) return send(409, { error: `Port ${p} is in use by another program. Pick another port.` });
170
+ writeConfig(cfg);
171
+ return send(200, { ok: true, port: p, url: `http://localhost:${p}/app`, configFile: CONFIG_FILE });
172
+ }
173
+ if (route === "POST /api/setup/finish") {
174
+ const cfg = readConfig();
175
+ if (!cfg) return send(400, { error: "Save your settings first." });
176
+ // Downloads still running (e.g. a voice you switched away from) keep going in
177
+ // this process after Ava starts; the page already waited for the ones you chose.
178
+ send(200, { ok: true, port: Number(cfg.PORT) || DEFAULT_PORT });
179
+ // let the response flush, then hand the port over to Ava
180
+ setTimeout(() => { server.closeAllConnections?.(); server.close(() => finish({ start: true, port: Number(cfg.PORT) || DEFAULT_PORT })); }, 150);
181
+ return;
182
+ }
183
+ return send(404, { error: "unknown endpoint" });
184
+ } catch (e) {
185
+ return send(500, { error: e.message });
186
+ }
187
+ });
188
+
189
+ listenPort = await findPort(preferredPort || Number(prev.PORT) || DEFAULT_PORT);
190
+ await new Promise((r) => server.listen(listenPort, "127.0.0.1", r));
191
+ listenPort = server.address().port;
192
+ const url = `http://localhost:${listenPort}/setup#token=${token}`;
193
+ onReady?.({ url, port: listenPort });
194
+ return finished;
195
+ }
@@ -0,0 +1,233 @@
1
+ // `ava-engine setup` in the terminal: the guided wizard (and its non-interactive twin).
2
+ // The browser version lives in setup-server.mjs; both save through apply.mjs.
3
+ import * as p from "@clack/prompts";
4
+ import { CONFIG_FILE, MODELS_DIR, DEFAULT_PORT, readConfig, writeConfig, checkOpenRouterKey, checkElevenLabsKey, portFree, suggestPassword } from "./config.mjs";
5
+ import { buildConfig, VOICES } from "./apply.mjs";
6
+ import { MODEL_PACKS, installedPacks, fetchPack } from "../fetch-models.mjs";
7
+ import { loadKokoro, kokoroInstalled, KOKORO_SIZE } from "../kokoro-model.mjs";
8
+
9
+ const mask = (v) => (v ? `${v.slice(0, 6)}…${v.slice(-4)}` : "");
10
+ function bail(v) { if (p.isCancel(v)) { p.cancel("Setup cancelled. Nothing was changed."); process.exit(0); } return v; }
11
+
12
+ /** Download speech model packs with a progress bar per pack. */
13
+ export async function downloadPacks(packs) {
14
+ for (const pack of packs) {
15
+ const spec = MODEL_PACKS[pack];
16
+ let bar = null, last = 0;
17
+ try {
18
+ await fetchPack(pack, MODELS_DIR, (ev) => {
19
+ if (ev.phase === "skip") return;
20
+ if (ev.phase === "download") {
21
+ if (!bar) { bar = p.progress({ max: 100, style: "heavy" }); bar.start(`Downloading ${spec.label} (${spec.size})`); }
22
+ const pct = ev.total ? Math.floor((ev.done / ev.total) * 100) : 0;
23
+ if (pct > last) { bar.advance(pct - last, `Downloading ${spec.label}: ${pct}%`); last = pct; }
24
+ }
25
+ if (ev.phase === "extract" && bar) bar.message(`Unpacking ${spec.label}…`);
26
+ });
27
+ if (bar) bar.stop(`${spec.label} ready`); else p.log.success(`${spec.label} already installed`);
28
+ } catch (e) {
29
+ if (bar) bar.error(`${spec.label} failed`);
30
+ p.log.error(`${e.message}\nYou can retry later with: npx ava-engine models ${pack}`);
31
+ }
32
+ }
33
+ }
34
+
35
+ /** Download the Kokoro voice now, so the first call doesn't wait for it. */
36
+ export async function downloadKokoro() {
37
+ process.env.AVA_MODELS_DIR ??= MODELS_DIR;
38
+ if (kokoroInstalled()) { p.log.success("Kokoro voice already installed"); return true; }
39
+ const bar = p.progress({ max: 100, style: "heavy" });
40
+ bar.start(`Downloading the Kokoro voice (${KOKORO_SIZE})`);
41
+ let last = 0;
42
+ try {
43
+ await loadKokoro({ onProgress: ({ pct }) => { if (pct > last) { bar.advance(pct - last, `Downloading the Kokoro voice: ${pct}%`); last = pct; } } });
44
+ bar.stop("Kokoro voice ready");
45
+ return true;
46
+ } catch (e) {
47
+ bar.error("Kokoro voice download failed");
48
+ p.log.error(`${e.message}\nAva will retry when it starts, or use the browser's voice meanwhile.`);
49
+ return false;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * The interactive wizard. Returns { start: boolean, port }.
55
+ * Existing settings are kept unless you change them.
56
+ */
57
+ export async function runSetup() {
58
+ process.env.AVA_MODELS_DIR ??= MODELS_DIR; // so "is Kokoro installed?" looks in ~/.ava-engine/models
59
+ const prev = readConfig() || {};
60
+ const answers = {};
61
+
62
+ console.log("");
63
+ p.intro(" ΛVΛ Ava Engine setup ");
64
+ p.note(
65
+ "A screen-aware voice agent: it joins a call, sees the tab your\n" +
66
+ "customer shares, and talks them through it.\n\n" +
67
+ "This takes about 2 minutes. You can change anything later with\n" +
68
+ "`npx ava-engine setup`, or by editing " + CONFIG_FILE,
69
+ "Welcome",
70
+ );
71
+
72
+ // 1 · AI replies
73
+ const mode = bail(await p.select({
74
+ message: "How should Ava think?",
75
+ options: [
76
+ { value: "full", label: "With real AI", hint: "recommended; needs an OpenRouter key" },
77
+ { value: "mock", label: "Just look around first", hint: "no key; canned replies, everything else works" },
78
+ ],
79
+ }));
80
+ if (mode === "mock") {
81
+ answers.openrouterKey = null;
82
+ p.log.info("MOCK mode: Ava will use canned replies. Add a key any time with `npx ava-engine setup`.");
83
+ } else {
84
+ let key = prev.OPENROUTER_API_KEY;
85
+ if (key && !bail(await p.confirm({ message: `Keep your current OpenRouter key (${mask(key)})?`, initialValue: true }))) key = undefined;
86
+ while (!key) {
87
+ p.log.message("Ava's AI runs through OpenRouter: one key for every model.\nGet one at https://openrouter.ai/keys (set a credit limit on it).");
88
+ const entered = bail(await p.password({ message: "Paste your OpenRouter API key", validate: (v) => (!v || v.trim().length < 20 ? "That doesn't look like a full key" : undefined) })).trim();
89
+ const s = p.spinner(); s.start("Checking the key with OpenRouter");
90
+ const r = await checkOpenRouterKey(entered);
91
+ if (r.ok) {
92
+ const named = r.label && !/^sk-or/i.test(r.label) ? ` (${r.label})` : ""; // unnamed keys report a masked key as their label
93
+ s.stop(`Key works${named}${r.limit != null ? `, credit limit $${r.limit}` : ""}`);
94
+ key = entered;
95
+ } else {
96
+ s.error(r.error);
97
+ const next = bail(await p.select({
98
+ message: "What now?",
99
+ options: [
100
+ { value: "retry", label: "Try another key" },
101
+ ...(r.offline ? [{ value: "keep", label: "Save it anyway", hint: "I'm offline right now" }] : []),
102
+ { value: "mock", label: "Skip for now", hint: "MOCK mode" },
103
+ ],
104
+ }));
105
+ if (next === "keep") key = entered;
106
+ if (next === "mock") break;
107
+ }
108
+ }
109
+ answers.openrouterKey = key || null;
110
+ }
111
+
112
+ // 2 · voice (free and offline by default, so offered in every mode)
113
+ answers.voice = bail(await p.select({
114
+ message: "Which voice should Ava speak with?",
115
+ initialValue: VOICES[prev.TTS_DEFAULT] ? prev.TTS_DEFAULT : "kokoro",
116
+ options: Object.entries(VOICES).map(([value, v]) => ({ value, label: v.label, hint: value === "kokoro" && kokoroInstalled() ? "free, runs on this computer (installed)" : v.hint })),
117
+ }));
118
+ if (answers.voice === "kokoro") await downloadKokoro();
119
+ if (answers.voice === "elevenlabs") {
120
+ let ek = prev.ELEVENLABS_API_KEY;
121
+ if (ek && !bail(await p.confirm({ message: `Keep your current ElevenLabs key (${mask(ek)})?`, initialValue: true }))) ek = undefined;
122
+ while (!ek) {
123
+ const entered = bail(await p.password({ message: "Paste your ElevenLabs API key (elevenlabs.io → API keys)" })).trim();
124
+ const s = p.spinner(); s.start("Checking the key with ElevenLabs");
125
+ const r = await checkElevenLabsKey(entered);
126
+ if (r.ok || r.offline) { s.stop(r.ok ? "Key works" : `${r.error}; saved anyway`); ek = entered; }
127
+ else {
128
+ s.error(r.error);
129
+ if (!bail(await p.confirm({ message: "Try another key?", initialValue: true }))) { answers.voice = "kokoro"; await downloadKokoro(); break; }
130
+ }
131
+ }
132
+ if (ek) {
133
+ answers.elevenKey = ek;
134
+ answers.elevenVoice = bail(await p.text({ message: "ElevenLabs voice ID (Enter for the default voice, Matilda)", placeholder: "XrExE9yKIg1WjnnlVkGX", defaultValue: prev.ELEVEN_VOICE_ID || "" })).trim() || null;
135
+ }
136
+ }
137
+
138
+ // 3 · speech recognition (offered in every mode)
139
+ const have = installedPacks(MODELS_DIR);
140
+ const packs = bail(await p.multiselect({
141
+ message: "Download offline speech recognition? (space to toggle, enter to confirm)",
142
+ required: false,
143
+ initialValues: have.length ? have : ["english-small"],
144
+ options: Object.entries(MODEL_PACKS).map(([id, m]) => ({ value: id, label: `${m.label} · ${m.size}`, hint: have.includes(id) ? "installed" : m.hint })),
145
+ }));
146
+ const missing = packs.filter((x) => !have.includes(x));
147
+ if (missing.length) await downloadPacks(missing);
148
+ else if (!packs.length) p.log.info("No models: Ava will use the browser's speech recognition (Chrome). Add them later with `npx ava-engine models`.");
149
+
150
+ // 4 · who can reach it
151
+ answers.audience = bail(await p.select({
152
+ message: "Who will use this Ava?",
153
+ initialValue: prev.ACCESS_PASSWORD ? "others" : "me",
154
+ options: [
155
+ { value: "me", label: "Just me, on this computer", hint: "safest; reachable only at localhost" },
156
+ { value: "others", label: "My team or customers", hint: "over a network or the internet; needs a password" },
157
+ ],
158
+ }));
159
+ if (answers.audience === "others") {
160
+ const suggested = suggestPassword();
161
+ const how = bail(await p.select({
162
+ message: "Set an access password (every call spends your API credit)",
163
+ options: [
164
+ ...(prev.ACCESS_PASSWORD ? [{ value: "keep", label: "Keep the current password" }] : []),
165
+ { value: "gen", label: `Use a generated one: ${suggested}`, hint: "recommended; it's saved in the settings file" },
166
+ { value: "own", label: "Choose my own" },
167
+ ],
168
+ }));
169
+ answers.password = how === "keep" ? prev.ACCESS_PASSWORD : how === "gen" ? suggested
170
+ : bail(await p.password({ message: "Access password (at least 10 characters)", validate: (v) => (!v || v.length < 10 ? "Use at least 10 characters" : undefined) }));
171
+ answers.proxy = bail(await p.confirm({ message: "Will it run behind a proxy or host (Fly.io, Cloudflare, nginx, Render…)?", initialValue: prev.TRUST_PROXY === "1" }));
172
+ }
173
+
174
+ // 5 · port
175
+ const portStr = bail(await p.text({
176
+ message: "Port",
177
+ defaultValue: String(prev.PORT || DEFAULT_PORT),
178
+ placeholder: String(prev.PORT || DEFAULT_PORT),
179
+ validate: (v) => { if (!v) return undefined; const n = Number(v); return Number.isInteger(n) && n > 0 && n < 65536 ? undefined : "Use a number between 1 and 65535"; },
180
+ }));
181
+ answers.port = Number(portStr) || DEFAULT_PORT;
182
+
183
+ // 6 · save
184
+ const cfg = buildConfig(prev, answers);
185
+ if (!(await portFree(Number(cfg.PORT), cfg.HOST))) p.log.warn(`Port ${cfg.PORT} is in use right now. Stop that program, or run \`npx ava-engine start --port <other>\`.`);
186
+ writeConfig(cfg);
187
+ p.note(
188
+ [
189
+ `AI replies ${cfg.OPENROUTER_API_KEY ? `OpenRouter (${mask(cfg.OPENROUTER_API_KEY)})` : "MOCK mode (canned replies)"}`,
190
+ `Voice ${VOICES[cfg.TTS_DEFAULT]?.label || "Kokoro"}`,
191
+ `Speech models ${installedPacks(MODELS_DIR).map((x) => MODEL_PACKS[x].label).join(", ") || "none (browser speech recognition)"}`,
192
+ `Access ${cfg.HOST === "127.0.0.1" ? "this computer only" : `password protected${cfg.ACCESS_PASSWORD ? ` (${cfg.ACCESS_PASSWORD})` : ""}`}`,
193
+ `Address http://localhost:${cfg.PORT}/app`,
194
+ "",
195
+ `Saved to ${CONFIG_FILE}`,
196
+ ].join("\n"),
197
+ "Your Ava",
198
+ );
199
+
200
+ const start = bail(await p.confirm({ message: "Start Ava now?", initialValue: true }));
201
+ p.outro(start ? "Starting Ava…" : "All set. Start Ava any time with: npx ava-engine");
202
+ return { start, port: Number(cfg.PORT) };
203
+ }
204
+
205
+ /**
206
+ * Non-interactive setup for scripts, servers and CI: settings come from the
207
+ * environment and flags, with safe defaults. Returns the written config.
208
+ */
209
+ export async function runSetupNonInteractive({ models = [], port } = {}) {
210
+ const prev = readConfig() || {};
211
+ const env = process.env;
212
+ const pick = (k) => env[k] ?? prev[k];
213
+ const cfg = { ...prev };
214
+ for (const k of ["OPENROUTER_API_KEY", "ELEVENLABS_API_KEY", "ELEVEN_VOICE_ID", "TTS_DEFAULT", "ACCESS_PASSWORD", "TRUST_PROXY", "KOKORO"]) if (pick(k)) cfg[k] = pick(k);
215
+ cfg.PORT = String(port || pick("PORT") || DEFAULT_PORT);
216
+ if (!cfg.ACCESS_PASSWORD) cfg.HOST = pick("HOST") || "127.0.0.1"; // never expose an open server by default
217
+ else if (pick("HOST")) cfg.HOST = pick("HOST");
218
+ cfg.AVA_MODELS_DIR = MODELS_DIR;
219
+ writeConfig(cfg);
220
+ for (const pack of models) {
221
+ if (pack === "kokoro") {
222
+ process.env.AVA_MODELS_DIR ??= MODELS_DIR;
223
+ console.log("↓ Kokoro voice");
224
+ if (!kokoroInstalled()) await loadKokoro();
225
+ continue;
226
+ }
227
+ if (!MODEL_PACKS[pack]) throw new Error(`unknown model "${pack}" (choose: ${Object.keys(MODEL_PACKS).join(", ")}, kokoro)`);
228
+ console.log(`↓ ${MODEL_PACKS[pack].label}`);
229
+ await fetchPack(pack, MODELS_DIR);
230
+ }
231
+ console.log(`Saved ${CONFIG_FILE}${cfg.OPENROUTER_API_KEY ? "" : " (MOCK mode: no OPENROUTER_API_KEY)"}`);
232
+ return cfg;
233
+ }
@@ -0,0 +1,104 @@
1
+ // Offline speech-to-text model downloads (sherpa-onnx). Dependency-free so the
2
+ // Dockerfile and the CLI can both use it:
3
+ //
4
+ // node lib/fetch-models.mjs [english-small|english|multilingual|all] [--dir <path>]
5
+ //
6
+ // Archives are extracted with the system `tar` (macOS, Linux and Windows 10+).
7
+
8
+ import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
9
+ import { join, resolve, dirname } from "node:path";
10
+ import { spawn } from "node:child_process";
11
+ import { Readable } from "node:stream";
12
+ import { pipeline } from "node:stream/promises";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const BASE = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models";
16
+
17
+ /** What the CLI offers. `files` are fetched in order; `dir` marks a pack as installed. */
18
+ export const MODEL_PACKS = {
19
+ "english-small": {
20
+ label: "English, fast", size: "122 MB",
21
+ hint: "Streaming English with live captions. A good default.",
22
+ files: [{ name: "sherpa-onnx-streaming-zipformer-en-20M-2023-02-17.tar.bz2", dir: "sherpa-onnx-streaming-zipformer-en-20M-2023-02-17" }],
23
+ },
24
+ english: {
25
+ label: "English, most accurate", size: "296 MB",
26
+ hint: "The larger streaming English model. Used instead of the small one when both are installed.",
27
+ files: [{ name: "sherpa-onnx-streaming-zipformer-en-2023-06-26.tar.bz2", dir: "sherpa-onnx-streaming-zipformer-en-2023-06-26" }],
28
+ },
29
+ multilingual: {
30
+ label: "Multilingual (zh/en/ja/ko/yue)", size: "1 GB",
31
+ hint: "SenseVoice with voice activity detection. Captions appear when the speaker pauses.",
32
+ files: [
33
+ { name: "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17.tar.bz2", dir: "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17" },
34
+ { name: "silero_vad.onnx", file: "silero_vad.onnx" },
35
+ ],
36
+ },
37
+ };
38
+
39
+ export function packInstalled(pack, dir) {
40
+ return MODEL_PACKS[pack].files.every((f) => existsSync(join(dir, f.dir || f.file)));
41
+ }
42
+
43
+ export function installedPacks(dir) {
44
+ return Object.keys(MODEL_PACKS).filter((p) => packInstalled(p, dir));
45
+ }
46
+
47
+ function untar(archive, cwd) {
48
+ return new Promise((res, rej) => {
49
+ const p = spawn("tar", ["xjf", archive], { cwd, stdio: ["ignore", "ignore", "pipe"] });
50
+ let err = "";
51
+ p.stderr.on("data", (d) => { err += d; });
52
+ p.on("error", (e) => rej(new Error(`couldn't run tar: ${e.message}`)));
53
+ p.on("close", (code) => (code === 0 ? res() : rej(new Error(`tar failed (${code}): ${err.trim()}`))));
54
+ });
55
+ }
56
+
57
+ async function download(url, dest, onProgress) {
58
+ const res = await fetch(url, { redirect: "follow" });
59
+ if (!res.ok || !res.body) throw new Error(`download failed: HTTP ${res.status} for ${url}`);
60
+ const total = Number(res.headers.get("content-length")) || 0;
61
+ let done = 0;
62
+ const body = Readable.fromWeb(res.body);
63
+ body.on("data", (c) => { done += c.length; onProgress?.(done, total); });
64
+ const tmp = dest + ".part";
65
+ await pipeline(body, createWriteStream(tmp));
66
+ renameSync(tmp, dest); // only a complete download gets the real name
67
+ }
68
+
69
+ /**
70
+ * Download and unpack one model pack into `dir`. Skips files already present.
71
+ * onProgress({ file, done, total, phase: "download" | "extract" | "skip" })
72
+ */
73
+ export async function fetchPack(pack, dir, onProgress) {
74
+ const spec = MODEL_PACKS[pack];
75
+ if (!spec) throw new Error(`unknown model pack "${pack}" (choose: ${Object.keys(MODEL_PACKS).join(", ")})`);
76
+ mkdirSync(dir, { recursive: true });
77
+ for (const f of spec.files) {
78
+ const target = join(dir, f.dir || f.file);
79
+ if (existsSync(target)) { onProgress?.({ file: f.name, phase: "skip" }); continue; }
80
+ const dest = join(dir, f.name);
81
+ await download(`${BASE}/${f.name}`, dest, (done, total) => onProgress?.({ file: f.name, phase: "download", done, total }));
82
+ if (f.dir) {
83
+ onProgress?.({ file: f.name, phase: "extract" });
84
+ try { await untar(f.name, dir); } finally { rmSync(dest, { force: true }); }
85
+ }
86
+ }
87
+ }
88
+
89
+ // ── command line ──
90
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
91
+ const args = process.argv.slice(2);
92
+ const di = args.indexOf("--dir");
93
+ const dir = resolve(di >= 0 ? args.splice(di, 2)[1] : join(dirname(fileURLToPath(import.meta.url)), "..", "models"));
94
+ const wanted = args[0] === "all" ? ["english", "multilingual"] : args.length ? args : ["english-small"];
95
+ for (const pack of wanted) {
96
+ let last = -1;
97
+ await fetchPack(pack, dir, (p) => {
98
+ if (p.phase === "skip") console.log(`✓ ${p.file} (already present)`);
99
+ else if (p.phase === "extract") console.log(` unpacking ${p.file}…`);
100
+ else if (p.total) { const pct = Math.floor((p.done / p.total) * 100); if (pct !== last && pct % 10 === 0) { last = pct; console.log(`↓ ${p.file} ${pct}%`); } }
101
+ });
102
+ }
103
+ console.log(`Models are in ${dir}`);
104
+ }
package/lib/guard.mjs ADDED
@@ -0,0 +1,92 @@
1
+ // Abuse protection for a publicly reachable server. Every /api call can spend
2
+ // the operator's model/voice credit, so a deploy should never be wide open.
3
+ //
4
+ // ACCESS_PASSWORD optional. When set, the app, specialist console, /api/* and
5
+ // the WebSockets require it (HTTP Basic auth, then a cookie so
6
+ // WebSocket upgrades are covered too). The landing page and
7
+ // static assets stay public.
8
+ // TRUST_PROXY=1 take the client IP from Fly-Client-IP / X-Forwarded-For
9
+ // (only behind a proxy you control — the header is spoofable).
10
+
11
+ import { createHmac, timingSafeEqual } from "node:crypto";
12
+
13
+ const PASSWORD = process.env.ACCESS_PASSWORD || "";
14
+ const TRUST_PROXY = process.env.TRUST_PROXY === "1";
15
+ export const AUTH_ENABLED = !!PASSWORD;
16
+
17
+ const COOKIE = "ava_auth";
18
+ const cookieValue = PASSWORD ? createHmac("sha256", PASSWORD).update("ava-access-v1").digest("hex") : "";
19
+
20
+ function safeEqual(a, b) {
21
+ const x = Buffer.from(String(a)), y = Buffer.from(String(b));
22
+ return x.length === y.length && timingSafeEqual(x, y);
23
+ }
24
+
25
+ function readCookie(req, name) {
26
+ for (const part of String(req.headers.cookie || "").split(";")) {
27
+ const i = part.indexOf("=");
28
+ if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /** True if the request carries a valid password (Basic auth) or auth cookie. */
34
+ export function isAuthorized(req) {
35
+ if (!AUTH_ENABLED) return true;
36
+ const c = readCookie(req, COOKIE);
37
+ if (c && safeEqual(c, cookieValue)) return true;
38
+ const h = String(req.headers.authorization || "");
39
+ if (h.startsWith("Basic ")) {
40
+ const pass = Buffer.from(h.slice(6), "base64").toString("utf8").split(":").slice(1).join(":");
41
+ return safeEqual(pass, PASSWORD);
42
+ }
43
+ return false;
44
+ }
45
+
46
+ /** Headers that ask the browser for the password. */
47
+ export function challengeHeaders() {
48
+ return { "WWW-Authenticate": 'Basic realm="Ava", charset="UTF-8"' };
49
+ }
50
+
51
+ /** Set-Cookie header that remembers a successful login (so WebSockets pass). */
52
+ export function authCookieHeader(req) {
53
+ if (!AUTH_ENABLED || readCookie(req, COOKIE) === cookieValue) return {};
54
+ const secure = TRUST_PROXY || req.socket.encrypted ? "; Secure" : "";
55
+ return { "Set-Cookie": `${COOKIE}=${cookieValue}; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200${secure}` };
56
+ }
57
+
58
+ export function clientIp(req) {
59
+ if (TRUST_PROXY) {
60
+ const fly = req.headers["fly-client-ip"];
61
+ if (fly) return String(fly);
62
+ const xff = req.headers["x-forwarded-for"];
63
+ if (xff) return String(xff).split(",")[0].trim();
64
+ }
65
+ return req.socket.remoteAddress || "?";
66
+ }
67
+
68
+ /** Reject cross-site WebSocket upgrades (browsers always send Origin). */
69
+ export function sameOrigin(req) {
70
+ const origin = req.headers.origin;
71
+ if (!origin) return true; // non-browser client; still subject to auth
72
+ try { return new URL(origin).host === req.headers.host; } catch { return false; }
73
+ }
74
+
75
+ /**
76
+ * Fixed-window rate limiter: limiter("turn", 120, 60_000)(ip) → true if allowed.
77
+ * Buckets are swept as they expire, so memory stays bounded by active clients.
78
+ */
79
+ export function limiter(name, max, windowMs) {
80
+ const hits = new Map();
81
+ setInterval(() => { const now = Date.now(); for (const [k, v] of hits) if (now >= v.reset) hits.delete(k); }, windowMs).unref();
82
+ const allow = (key) => {
83
+ const now = Date.now();
84
+ let b = hits.get(key);
85
+ if (!b || now >= b.reset) { b = { n: 0, reset: now + windowMs }; hits.set(key, b); }
86
+ b.n += 1;
87
+ return b.n <= max;
88
+ };
89
+ /** True while `key` is over the limit, without counting a new hit. */
90
+ allow.blocked = (key) => { const b = hits.get(key); return !!b && Date.now() < b.reset && b.n >= max; };
91
+ return allow;
92
+ }