ava-engine 0.0.0-stage → 0.1.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 (98) 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 +238 -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 +143 -0
  10. package/lib/cli/config.mjs +95 -0
  11. package/lib/cli/main.mjs +152 -0
  12. package/lib/cli/setup.mjs +219 -0
  13. package/lib/fetch-models.mjs +104 -0
  14. package/lib/guard.mjs +92 -0
  15. package/lib/models.mjs +47 -0
  16. package/lib/openrouter.mjs +153 -0
  17. package/lib/ours.mjs +766 -0
  18. package/lib/platforms.mjs +201 -0
  19. package/lib/state.mjs +150 -0
  20. package/lib/stt.mjs +22 -0
  21. package/lib/tts.mjs +178 -0
  22. package/lib/workflow.mjs +18 -0
  23. package/package.json +72 -4
  24. package/public/app.js +1708 -0
  25. package/public/demo.js +420 -0
  26. package/public/favicon.svg +1 -0
  27. package/public/fonts/OFL.txt +96 -0
  28. package/public/fonts/inter-tight-var.woff2 +0 -0
  29. package/public/fonts/inter-var.woff2 +0 -0
  30. package/public/fonts/plex-mono-400.woff2 +0 -0
  31. package/public/fonts/plex-mono-500.woff2 +0 -0
  32. package/public/img/cta-1600.avif +0 -0
  33. package/public/img/cta-1600.webp +0 -0
  34. package/public/img/cta-2400.avif +0 -0
  35. package/public/img/cta-2400.webp +0 -0
  36. package/public/img/cta-960.avif +0 -0
  37. package/public/img/cta-960.webp +0 -0
  38. package/public/img/flows-1600.avif +0 -0
  39. package/public/img/flows-1600.webp +0 -0
  40. package/public/img/flows-2400.avif +0 -0
  41. package/public/img/flows-2400.webp +0 -0
  42. package/public/img/flows-960.avif +0 -0
  43. package/public/img/flows-960.webp +0 -0
  44. package/public/img/hero-1600.avif +0 -0
  45. package/public/img/hero-1600.webp +0 -0
  46. package/public/img/hero-2400.avif +0 -0
  47. package/public/img/hero-2400.webp +0 -0
  48. package/public/img/hero-960.avif +0 -0
  49. package/public/img/hero-960.webp +0 -0
  50. package/public/img/p-csm-480.avif +0 -0
  51. package/public/img/p-csm-480.webp +0 -0
  52. package/public/img/p-csm-800.avif +0 -0
  53. package/public/img/p-csm-800.webp +0 -0
  54. package/public/img/p-docs-480.avif +0 -0
  55. package/public/img/p-docs-480.webp +0 -0
  56. package/public/img/p-docs-800.avif +0 -0
  57. package/public/img/p-docs-800.webp +0 -0
  58. package/public/img/p-quiet-480.avif +0 -0
  59. package/public/img/p-quiet-480.webp +0 -0
  60. package/public/img/p-quiet-800.avif +0 -0
  61. package/public/img/p-quiet-800.webp +0 -0
  62. package/public/img/p-tickets-480.avif +0 -0
  63. package/public/img/p-tickets-480.webp +0 -0
  64. package/public/img/p-tickets-800.avif +0 -0
  65. package/public/img/p-tickets-800.webp +0 -0
  66. package/public/img/p-trial-480.avif +0 -0
  67. package/public/img/p-trial-480.webp +0 -0
  68. package/public/img/p-trial-800.avif +0 -0
  69. package/public/img/p-trial-800.webp +0 -0
  70. package/public/img/sees-1600.avif +0 -0
  71. package/public/img/sees-1600.webp +0 -0
  72. package/public/img/sees-2400.avif +0 -0
  73. package/public/img/sees-2400.webp +0 -0
  74. package/public/img/sees-960.avif +0 -0
  75. package/public/img/sees-960.webp +0 -0
  76. package/public/img/speaks-1600.avif +0 -0
  77. package/public/img/speaks-1600.webp +0 -0
  78. package/public/img/speaks-2400.avif +0 -0
  79. package/public/img/speaks-2400.webp +0 -0
  80. package/public/img/speaks-960.avif +0 -0
  81. package/public/img/speaks-960.webp +0 -0
  82. package/public/img/voice-1600.avif +0 -0
  83. package/public/img/voice-1600.webp +0 -0
  84. package/public/img/voice-2400.avif +0 -0
  85. package/public/img/voice-2400.webp +0 -0
  86. package/public/img/voice-960.avif +0 -0
  87. package/public/img/voice-960.webp +0 -0
  88. package/public/index.html +440 -0
  89. package/public/landing.html +975 -0
  90. package/public/pcm-worklet.js +11 -0
  91. package/public/perceptionWorker.js +103 -0
  92. package/public/rtc.js +60 -0
  93. package/public/specialist.html +92 -0
  94. package/public/specialist.js +132 -0
  95. package/public/style.css +836 -0
  96. package/public/vendor/lenis.min.js +3 -0
  97. package/scripts/fetch-models.sh +15 -0
  98. package/server.mjs +502 -0
package/lib/asr.mjs ADDED
@@ -0,0 +1,143 @@
1
+ // Offline streaming speech-to-text — free, on-device, no key. Two engines the
2
+ // user picks before the call:
3
+ // - "zipformer" : English streaming zipformer. Live word-by-word partials.
4
+ // - "sensevoice" : SenseVoice multilingual (zh/en/ja/ko/yue). Utterance-based
5
+ // via Silero VAD; cased + punctuated; more accurate on accents.
6
+
7
+ import { createRequire } from "node:module";
8
+ import { existsSync, readdirSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const __dir = dirname(fileURLToPath(import.meta.url));
14
+ const MODELS = process.env.AVA_MODELS_DIR || join(__dir, "../models"); // the CLI points this at ~/.ava-engine/models
15
+
16
+ // zipformer: prefer the larger model, fall back to the 20M; SHERPA_MODEL_DIR overrides
17
+ const ZIP_CANDIDATES = [
18
+ process.env.SHERPA_MODEL_DIR,
19
+ join(MODELS, "sherpa-onnx-streaming-zipformer-en-2023-06-26"),
20
+ join(MODELS, "sherpa-onnx-streaming-zipformer-en-20M-2023-02-17"),
21
+ ].filter(Boolean);
22
+ const ZIP_DIR = ZIP_CANDIDATES.find((d) => existsSync(join(d, "tokens.txt")));
23
+ const SV_DIR = join(MODELS, "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17");
24
+ const VAD_MODEL = join(MODELS, "silero_vad.onnx");
25
+
26
+ let sherpa = null, online = null, offline = null, initErr = null;
27
+
28
+ function loadSherpa() {
29
+ if (!sherpa && !initErr) { try { sherpa = require("sherpa-onnx-node"); } catch (e) { initErr = e; console.log(" sherpa unavailable:", e.message); } }
30
+ return sherpa;
31
+ }
32
+
33
+ // Which engines have their model files on disk.
34
+ export function availableEngines() {
35
+ const e = [];
36
+ if (ZIP_DIR) e.push("zipformer");
37
+ if (existsSync(join(SV_DIR, "tokens.txt")) && existsSync(VAD_MODEL)) e.push("sensevoice");
38
+ return e;
39
+ }
40
+ export function asrAvailable() { return !initErr && availableEngines().length > 0; }
41
+ export function defaultEngine() { const e = availableEngines(); return e.includes("zipformer") ? "zipformer" : (e[0] || null); }
42
+ export const ENGINE_LABELS = { zipformer: "English · streaming", sensevoice: "Multilingual · SenseVoice" };
43
+
44
+ function pick(dir, kind, preferInt8) {
45
+ const files = readdirSync(dir).filter((f) => f.startsWith(kind) && f.endsWith(".onnx"));
46
+ const int8 = files.find((f) => f.includes("int8")), fp = files.find((f) => !f.includes("int8"));
47
+ return join(dir, preferInt8 ? int8 || fp : fp || int8);
48
+ }
49
+
50
+ function getOnline() {
51
+ if (online || !ZIP_DIR) return online;
52
+ const s = loadSherpa(); if (!s) return null;
53
+ online = new s.OnlineRecognizer({
54
+ featConfig: { sampleRate: 16000, featureDim: 80 },
55
+ modelConfig: {
56
+ transducer: { encoder: pick(ZIP_DIR, "encoder", true), decoder: pick(ZIP_DIR, "decoder", false), joiner: pick(ZIP_DIR, "joiner", true) },
57
+ tokens: join(ZIP_DIR, "tokens.txt"), numThreads: 2, provider: "cpu",
58
+ },
59
+ decodingMethod: "greedy_search",
60
+ enableEndpoint: true, rule1MinTrailingSilence: 2.4, rule2MinTrailingSilence: 0.9, rule3MinUtteranceLength: 20,
61
+ });
62
+ return online;
63
+ }
64
+ function getOffline() {
65
+ if (offline) return offline;
66
+ const s = loadSherpa(); if (!s) return null;
67
+ offline = new s.OfflineRecognizer({
68
+ featConfig: { sampleRate: 16000, featureDim: 80 },
69
+ modelConfig: {
70
+ senseVoice: { model: pick(SV_DIR, "model", true), language: process.env.SENSEVOICE_LANG || "en", useInverseTextNormalization: 1 },
71
+ tokens: join(SV_DIR, "tokens.txt"), numThreads: 2, provider: "cpu",
72
+ },
73
+ decodingMethod: "greedy_search",
74
+ });
75
+ return offline;
76
+ }
77
+
78
+ export function warmAsr() {
79
+ if (!asrAvailable()) { console.log(" sherpa ASR: no models found at", MODELS); return; }
80
+ const e = availableEngines();
81
+ if (e.includes("zipformer") && getOnline()) console.log(" sherpa ASR ready — English streaming (zipformer)");
82
+ if (e.includes("sensevoice")) console.log(" sherpa ASR ready — multilingual (SenseVoice), lazy-loaded on first use");
83
+ }
84
+
85
+ // SenseVoice text can carry <|lang|><|emo|> tags; strip them.
86
+ function cleanSV(t) { return String(t || "").replace(/<\|[^|]*\|>/g, "").replace(/\s+/g, " ").trim(); }
87
+ // ws hands us pooled Buffers at arbitrary byte offsets — read alignment-safe.
88
+ function toFloat32(buf) {
89
+ if (buf instanceof Int16Array) { const o = new Float32Array(buf.length); for (let i = 0; i < buf.length; i++) o[i] = buf[i] / 32768; return o; }
90
+ const n = Math.floor(buf.length / 2); const o = new Float32Array(n); for (let i = 0; i < n; i++) o[i] = buf.readInt16LE(i * 2) / 32768; return o;
91
+ }
92
+
93
+ /** One live transcription session for a WebSocket connection. */
94
+ export function createAsrSession(engine) {
95
+ const eng = availableEngines().includes(engine) ? engine : defaultEngine();
96
+ if (!eng) return null;
97
+ return eng === "sensevoice" ? sensevoiceSession() : zipformerSession();
98
+ }
99
+
100
+ function zipformerSession() {
101
+ const rec = getOnline(); if (!rec) return null;
102
+ const stream = rec.createStream(); let last = "";
103
+ return {
104
+ engine: "zipformer",
105
+ feed(buf) {
106
+ const samples = toFloat32(buf);
107
+ stream.acceptWaveform({ sampleRate: 16000, samples });
108
+ while (rec.isReady(stream)) rec.decode(stream);
109
+ const text = (rec.getResult(stream).text || "").trim();
110
+ const out = {};
111
+ if (rec.isEndpoint(stream)) { if (text) out.final = text; rec.reset(stream); last = ""; }
112
+ else if (text && text !== last) { last = text; out.partial = text; }
113
+ return out;
114
+ },
115
+ close() { try { stream.free && stream.free(); } catch {} },
116
+ };
117
+ }
118
+
119
+ function sensevoiceSession() {
120
+ const rec = getOffline(); const s = loadSherpa(); if (!rec || !s) return null;
121
+ const vad = new s.Vad({
122
+ sileroVad: { model: VAD_MODEL, threshold: 0.5, minSilenceDuration: 0.5, minSpeechDuration: 0.25, windowSize: 512 },
123
+ sampleRate: 16000, numThreads: 1, provider: "cpu",
124
+ }, 30);
125
+ return {
126
+ engine: "sensevoice",
127
+ feed(buf) {
128
+ vad.acceptWaveform(toFloat32(buf));
129
+ const out = {};
130
+ while (!vad.isEmpty()) {
131
+ const seg = vad.front(); vad.pop();
132
+ const st = rec.createStream();
133
+ st.acceptWaveform({ sampleRate: 16000, samples: seg.samples });
134
+ rec.decode(st);
135
+ const text = cleanSV(rec.getResult(st).text);
136
+ try { st.free && st.free(); } catch {}
137
+ if (text) out.final = out.final ? out.final + " " + text : text;
138
+ }
139
+ return out;
140
+ },
141
+ close() { try { vad.free && vad.free(); } catch {} },
142
+ };
143
+ }
@@ -0,0 +1,95 @@
1
+ // Where the installed CLI keeps its state, and small helpers the commands share.
2
+ //
3
+ // ~/.ava-engine/.env settings (API keys, password, port…), mode 600
4
+ // ~/.ava-engine/models/ offline speech-to-text models
5
+ //
6
+ // AVA_HOME moves the whole folder (handy for servers, Docker and tests).
7
+
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { createServer } from "node:net";
12
+ import { spawn } from "node:child_process";
13
+ import { randomInt } from "node:crypto";
14
+
15
+ export const AVA_HOME = process.env.AVA_HOME || join(homedir(), ".ava-engine");
16
+ export const CONFIG_FILE = join(AVA_HOME, ".env");
17
+ export const MODELS_DIR = join(AVA_HOME, "models");
18
+ export const DEFAULT_PORT = 5190;
19
+
20
+ /** Parse a .env file into an object (no expansion, last value wins). */
21
+ export function readConfig(file = CONFIG_FILE) {
22
+ if (!existsSync(file)) return null;
23
+ const out = {};
24
+ for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
25
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
26
+ if (!m) continue;
27
+ let v = m[2];
28
+ if (/^".*"$/.test(v)) v = v.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
29
+ out[m[1]] = v;
30
+ }
31
+ return out;
32
+ }
33
+
34
+ const quote = (v) => (/^[A-Za-z0-9_@%+=:,./-]*$/.test(v) ? v : `"${String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`);
35
+
36
+ /** Write settings to the config file (readable only by you). Empty values are dropped. */
37
+ export function writeConfig(values, file = CONFIG_FILE) {
38
+ mkdirSync(AVA_HOME, { recursive: true });
39
+ const lines = [
40
+ "# Ava Engine settings, written by `ava-engine setup`. Edit freely, or re-run setup.",
41
+ "# Every option is documented at https://github.com/Mr-vero/ava-engine/blob/main/.env.example",
42
+ "",
43
+ ...Object.entries(values).filter(([, v]) => v !== undefined && v !== null && v !== "").map(([k, v]) => `${k}=${quote(v)}`),
44
+ "",
45
+ ];
46
+ writeFileSync(file, lines.join("\n"), { mode: 0o600 });
47
+ try { chmodSync(file, 0o600); } catch {}
48
+ }
49
+
50
+ /** Check an OpenRouter key. Returns { ok, label?, limit?, error? }. */
51
+ export async function checkOpenRouterKey(key) {
52
+ try {
53
+ const r = await fetch("https://openrouter.ai/api/v1/key", { headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(10_000) });
54
+ if (r.status === 401 || r.status === 403) return { ok: false, error: "OpenRouter rejected this key" };
55
+ if (!r.ok) return { ok: false, error: `OpenRouter answered ${r.status}` };
56
+ const d = (await r.json()).data || {};
57
+ return { ok: true, label: d.label, limit: d.limit, usage: d.usage };
58
+ } catch (e) {
59
+ return { ok: false, error: `couldn't reach OpenRouter (${e.name === "TimeoutError" ? "timed out" : e.message})`, offline: true };
60
+ }
61
+ }
62
+
63
+ /** Check an ElevenLabs key. Returns { ok, error? }. */
64
+ export async function checkElevenLabsKey(key) {
65
+ try {
66
+ const r = await fetch("https://api.elevenlabs.io/v1/user", { headers: { "xi-api-key": key }, signal: AbortSignal.timeout(10_000) });
67
+ if (r.status === 401 || r.status === 403) return { ok: false, error: "ElevenLabs rejected this key" };
68
+ return r.ok ? { ok: true } : { ok: false, error: `ElevenLabs answered ${r.status}` };
69
+ } catch (e) {
70
+ return { ok: false, error: `couldn't reach ElevenLabs (${e.message})`, offline: true };
71
+ }
72
+ }
73
+
74
+ /** True if nothing is listening on the port. */
75
+ export function portFree(port, host) {
76
+ return new Promise((res) => {
77
+ const s = createServer();
78
+ s.once("error", () => res(false));
79
+ s.listen({ port, host }, () => s.close(() => res(true)));
80
+ });
81
+ }
82
+
83
+ export function openBrowser(url) {
84
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]]
85
+ : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
86
+ : ["xdg-open", [url]];
87
+ try { spawn(cmd, args, { stdio: "ignore", detached: true }).unref(); } catch {}
88
+ }
89
+
90
+ /** A random password like "k7fq-9xm4-r3pz": 12 characters, 60 bits, no look-alike letters. */
91
+ export function suggestPassword() {
92
+ const alphabet = "abcdefghjkmnpqrstuvwxyz23456789"; // 31 chars: no i, l, o, 0, 1
93
+ const group = () => Array.from({ length: 4 }, () => alphabet[randomInt(alphabet.length)]).join("");
94
+ return `${group()}-${group()}-${group()}`;
95
+ }
@@ -0,0 +1,152 @@
1
+ // The `ava-engine` command.
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createRequire } from "node:module";
6
+ import { AVA_HOME, CONFIG_FILE, MODELS_DIR, DEFAULT_PORT, readConfig, checkOpenRouterKey, portFree, openBrowser } from "./config.mjs";
7
+ import { MODEL_PACKS, installedPacks } from "../fetch-models.mjs";
8
+
9
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
10
+ const VERSION = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version;
11
+
12
+ const HELP = `
13
+ ΛVΛ Ava Engine ${VERSION}: a screen-aware voice agent
14
+
15
+ Usage
16
+ 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 start start the server
19
+ 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(", ")}
21
+
22
+ Options
23
+ --port <n> port to listen on (default ${DEFAULT_PORT})
24
+ --open open the browser once Ava is up (default on a first run)
25
+ --no-open don't open the browser
26
+ --yes setup without questions, from environment variables
27
+ --models <a,b> with --yes: model packs to download
28
+ -v, --version print the version
29
+ -h, --help show this help
30
+
31
+ Settings live in ${CONFIG_FILE}
32
+ (set AVA_HOME to keep them somewhere else). Docs: https://github.com/Mr-vero/ava-engine
33
+ `;
34
+
35
+ function parseArgs(argv) {
36
+ const out = { _: [], flags: {} };
37
+ for (let i = 0; i < argv.length; i++) {
38
+ const a = argv[i];
39
+ if (a === "-h" || a === "--help") out.flags.help = true;
40
+ else if (a === "-v" || a === "--version") out.flags.version = true;
41
+ else if (a === "--yes" || a === "-y") out.flags.yes = true;
42
+ else if (a === "--open") out.flags.open = true;
43
+ else if (a === "--no-open") out.flags.open = false;
44
+ else if (a === "--port") out.flags.port = Number(argv[++i]);
45
+ else if (a.startsWith("--port=")) out.flags.port = Number(a.slice(7));
46
+ else if (a === "--models") out.flags.models = String(argv[++i] || "").split(",").filter(Boolean);
47
+ else if (a.startsWith("--")) throw new Error(`unknown option ${a} (see --help)`);
48
+ else out._.push(a);
49
+ }
50
+ if (out.flags.port !== undefined && !(Number.isInteger(out.flags.port) && out.flags.port > 0 && out.flags.port < 65536)) throw new Error("--port needs a number between 1 and 65535");
51
+ return out;
52
+ }
53
+
54
+ /** Load the saved settings into the environment (real env vars win), then run the server. */
55
+ async function start({ port, open }) {
56
+ const cfg = readConfig() || {};
57
+ for (const [k, v] of Object.entries(cfg)) if (process.env[k] === undefined) process.env[k] = v;
58
+ process.env.AVA_MODELS_DIR ??= MODELS_DIR;
59
+ if (port) process.env.PORT = String(port);
60
+ const p = Number(process.env.PORT) || DEFAULT_PORT;
61
+ if (!(await portFree(p, process.env.HOST))) {
62
+ console.error(`\n ✗ Port ${p} is already in use. Try: npx ava-engine start --port ${p + 1}\n`);
63
+ process.exit(1);
64
+ }
65
+ await import("../../server.mjs");
66
+ if (open) {
67
+ const url = `http://localhost:${p}/app`;
68
+ for (let i = 0; i < 50; i++) { // wait until the server answers, then open the app
69
+ try { if ((await fetch(`http://127.0.0.1:${p}/api/health`)).ok) break; } catch {}
70
+ await new Promise((r) => setTimeout(r, 200));
71
+ }
72
+ openBrowser(url);
73
+ }
74
+ }
75
+
76
+ async function doctor() {
77
+ const cfg = readConfig();
78
+ const rows = [];
79
+ const add = (ok, label, fix) => rows.push({ ok, label, fix });
80
+ const [maj, min] = process.versions.node.split(".").map(Number);
81
+ add(maj > 22 || (maj === 22 && min >= 9) ? "ok" : "fail", `Node ${process.versions.node}`, "Ava needs Node 22.9 or newer: https://nodejs.org");
82
+ add(cfg ? "ok" : "fail", cfg ? `Settings: ${CONFIG_FILE}` : "No settings yet", "Run: npx ava-engine setup");
83
+ const c = cfg || {};
84
+ if (c.OPENROUTER_API_KEY) {
85
+ const r = await checkOpenRouterKey(c.OPENROUTER_API_KEY);
86
+ add(r.ok ? "ok" : r.offline ? "warn" : "fail", r.ok ? `OpenRouter key works${r.limit != null ? ` (limit $${r.limit}, used $${(r.usage ?? 0).toFixed(2)})` : ""}` : r.error, "Check the key at https://openrouter.ai/keys, then: npx ava-engine setup");
87
+ } else add("warn", "No OpenRouter key: MOCK mode (canned replies)", "Add one with: npx ava-engine setup");
88
+ if (c.TTS_DEFAULT === "elevenlabs" && !c.ELEVENLABS_API_KEY) add("fail", "Voice is ElevenLabs but there's no ElevenLabs key", "Run: npx ava-engine setup");
89
+ const packs = installedPacks(c.AVA_MODELS_DIR || MODELS_DIR);
90
+ add(packs.length ? "ok" : "warn", packs.length ? `Speech models: ${packs.map((x) => MODEL_PACKS[x].label).join(", ")}` : "No offline speech models: browser speech recognition only", "Download with: npx ava-engine models english-small");
91
+ try { createRequire(import.meta.url)("sherpa-onnx-node"); add("ok", "Speech engine (sherpa-onnx) loads"); }
92
+ catch (e) { add(packs.length ? "fail" : "warn", `Speech engine (sherpa-onnx) doesn't load: ${e.message.split("\n")[0]}`, "Reinstall: npx --yes ava-engine@latest (Linux also needs libgomp1)"); }
93
+ if (c.HOST !== "127.0.0.1" && !c.ACCESS_PASSWORD) add("warn", "Reachable from the network with NO password: anyone can spend your API credit", "Run setup and choose a password, or set HOST=127.0.0.1");
94
+ const port = Number(c.PORT) || DEFAULT_PORT;
95
+ const free = await portFree(port, c.HOST);
96
+ add(free ? "ok" : "warn", `Port ${port} ${free ? "is free" : "is in use (maybe Ava is already running)"}`, `Use another: npx ava-engine start --port ${port + 1}`);
97
+
98
+ const icon = { ok: "✓", warn: "!", fail: "✗" };
99
+ console.log(`\n ΛVΛ Ava Engine doctor (${VERSION})\n`);
100
+ for (const r of rows) console.log(` ${icon[r.ok]} ${r.label}${r.ok !== "ok" && r.fix ? `\n → ${r.fix}` : ""}`);
101
+ const failed = rows.some((r) => r.ok === "fail");
102
+ console.log(failed ? "\n Some checks failed. Fix those, then run doctor again.\n" : "\n Ready. Start Ava with: npx ava-engine\n");
103
+ return failed ? 1 : 0;
104
+ }
105
+
106
+ export async function main(argv) {
107
+ let args;
108
+ try { args = parseArgs(argv); } catch (e) { console.error(`✗ ${e.message}`); return 2; }
109
+ const { flags } = args;
110
+ if (flags.version) { console.log(VERSION); return 0; }
111
+ const cmd = args._[0] || (flags.help ? "help" : existsSync(CONFIG_FILE) ? "start" : "first-run");
112
+ const interactive = process.stdin.isTTY && process.stdout.isTTY && !flags.yes;
113
+
114
+ switch (cmd) {
115
+ case "help": console.log(HELP); return 0;
116
+ case "doctor": return doctor();
117
+ case "start": await start({ port: flags.port, open: flags.open === true }); return null; // keeps running
118
+ case "models": {
119
+ const { downloadPacks } = await import("./setup.mjs");
120
+ let packs = args._.slice(1);
121
+ if (!packs.length && interactive) {
122
+ const p = await import("@clack/prompts");
123
+ const have = installedPacks(MODELS_DIR);
124
+ const sel = await p.multiselect({ message: "Which speech models?", required: false, initialValues: have, options: Object.entries(MODEL_PACKS).map(([id, m]) => ({ value: id, label: `${m.label} · ${m.size}`, hint: have.includes(id) ? "installed" : m.hint })) });
125
+ if (p.isCancel(sel)) return 0;
126
+ packs = sel;
127
+ }
128
+ 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);
132
+ return 0;
133
+ }
134
+ case "setup":
135
+ case "first-run": {
136
+ const setup = await import("./setup.mjs");
137
+ if (!interactive) {
138
+ await setup.runSetupNonInteractive({ models: flags.models || [], port: flags.port });
139
+ if (cmd === "first-run") { await start({ port: flags.port, open: flags.open === true }); return null; }
140
+ return 0;
141
+ }
142
+ const r = await setup.runSetup();
143
+ if (r.start) { await start({ port: flags.port || r.port, open: flags.open !== false }); return null; }
144
+ return 0;
145
+ }
146
+ default:
147
+ console.error(`✗ unknown command "${cmd}" (see --help)`);
148
+ return 2;
149
+ }
150
+ }
151
+
152
+ export { AVA_HOME };
@@ -0,0 +1,219 @@
1
+ // `ava-engine setup`: the guided first-run wizard (and its non-interactive twin).
2
+ import * as p from "@clack/prompts";
3
+ import { AVA_HOME, CONFIG_FILE, MODELS_DIR, DEFAULT_PORT, readConfig, writeConfig, checkOpenRouterKey, checkElevenLabsKey, portFree, suggestPassword } from "./config.mjs";
4
+ import { MODEL_PACKS, installedPacks, fetchPack } from "../fetch-models.mjs";
5
+
6
+ const mask = (v) => (v ? `${v.slice(0, 6)}…${v.slice(-4)}` : "");
7
+ function bail(v) { if (p.isCancel(v)) { p.cancel("Setup cancelled. Nothing was changed."); process.exit(0); } return v; }
8
+
9
+ /** Download model packs with a progress bar per file. */
10
+ export async function downloadPacks(packs) {
11
+ for (const pack of packs) {
12
+ const spec = MODEL_PACKS[pack];
13
+ let bar = null, last = 0;
14
+ try {
15
+ await fetchPack(pack, MODELS_DIR, (ev) => {
16
+ if (ev.phase === "skip") return;
17
+ if (ev.phase === "download") {
18
+ if (!bar) { bar = p.progress({ max: 100, style: "heavy" }); bar.start(`Downloading ${spec.label} (${spec.size})`); }
19
+ const pct = ev.total ? Math.floor((ev.done / ev.total) * 100) : 0;
20
+ if (pct > last) { bar.advance(pct - last, `Downloading ${spec.label}: ${pct}%`); last = pct; }
21
+ }
22
+ if (ev.phase === "extract" && bar) bar.message(`Unpacking ${spec.label}…`);
23
+ });
24
+ if (bar) bar.stop(`${spec.label} ready`); else p.log.success(`${spec.label} already installed`);
25
+ } catch (e) {
26
+ if (bar) bar.error(`${spec.label} failed`);
27
+ p.log.error(`${e.message}\nYou can retry later with: ava-engine models ${pack}`);
28
+ }
29
+ }
30
+ }
31
+
32
+ /**
33
+ * The interactive wizard. Returns { start: boolean, port }.
34
+ * Existing settings are kept unless you change them.
35
+ */
36
+ export async function runSetup() {
37
+ const prev = readConfig() || {};
38
+ const cfg = { ...prev };
39
+
40
+ console.log("");
41
+ p.intro(" ΛVΛ Ava Engine setup ");
42
+ p.note(
43
+ "A screen-aware voice agent: it joins a call, sees the tab your\n" +
44
+ "customer shares, and talks them through it.\n\n" +
45
+ "This takes about 2 minutes. You can change anything later with\n" +
46
+ "`ava-engine setup`, or by editing " + CONFIG_FILE,
47
+ "Welcome",
48
+ );
49
+
50
+ // 1 · how to start
51
+ const mode = bail(await p.select({
52
+ message: "How do you want to start?",
53
+ options: [
54
+ { value: "full", label: "Full setup", hint: "recommended: real AI replies, voice, speech recognition" },
55
+ { value: "mock", label: "Just look around", hint: "no API keys; canned replies, everything else works" },
56
+ ],
57
+ }));
58
+
59
+ // 2 · language models
60
+ if (mode === "mock") {
61
+ delete cfg.OPENROUTER_API_KEY;
62
+ p.log.info("MOCK mode: Ava will use canned replies. Run `ava-engine setup` again any time to add a key.");
63
+ } else {
64
+ let key = prev.OPENROUTER_API_KEY;
65
+ if (key) {
66
+ const keep = bail(await p.confirm({ message: `Keep your current OpenRouter key (${mask(key)})?`, initialValue: true }));
67
+ if (!keep) key = undefined;
68
+ }
69
+ while (!key) {
70
+ 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).");
71
+ 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();
72
+ const s = p.spinner(); s.start("Checking the key with OpenRouter");
73
+ const r = await checkOpenRouterKey(entered);
74
+ if (r.ok) {
75
+ const named = r.label && !/^sk-or/i.test(r.label) ? ` (${r.label})` : ""; // unnamed keys report a masked key as their label
76
+ s.stop(`Key works${named}${r.limit != null ? `, credit limit $${r.limit}` : ""}`);
77
+ key = entered;
78
+ } else {
79
+ s.error(r.error);
80
+ const next = bail(await p.select({
81
+ message: "What now?",
82
+ options: [
83
+ { value: "retry", label: "Try another key" },
84
+ ...(r.offline ? [{ value: "keep", label: "Save it anyway", hint: "I'm offline right now" }] : []),
85
+ { value: "mock", label: "Skip for now", hint: "MOCK mode" },
86
+ ],
87
+ }));
88
+ if (next === "keep") key = entered;
89
+ if (next === "mock") break;
90
+ }
91
+ }
92
+ if (key) cfg.OPENROUTER_API_KEY = key; else delete cfg.OPENROUTER_API_KEY;
93
+
94
+ // 3 · voice
95
+ const voice = bail(await p.select({
96
+ message: "Which voice should Ava speak with?",
97
+ initialValue: prev.TTS_DEFAULT || "kokoro",
98
+ options: [
99
+ { value: "kokoro", label: "Kokoro", hint: "free, runs on this machine; downloads ~90 MB on first start" },
100
+ { value: "elevenlabs", label: "ElevenLabs", hint: "premium cloud voices, needs an ElevenLabs key" },
101
+ { value: "browser", label: "The browser's voice", hint: "no setup, sounds robotic" },
102
+ ],
103
+ }));
104
+ cfg.TTS_DEFAULT = voice;
105
+ cfg.KOKORO = voice === "kokoro" ? "on" : prev.KOKORO === "on" ? "on" : "off";
106
+ if (voice === "elevenlabs") {
107
+ let ek = prev.ELEVENLABS_API_KEY;
108
+ if (ek && !bail(await p.confirm({ message: `Keep your current ElevenLabs key (${mask(ek)})?`, initialValue: true }))) ek = undefined;
109
+ while (!ek) {
110
+ const entered = bail(await p.password({ message: "Paste your ElevenLabs API key (elevenlabs.io → API keys)" })).trim();
111
+ const s = p.spinner(); s.start("Checking the key with ElevenLabs");
112
+ const r = await checkElevenLabsKey(entered);
113
+ if (r.ok || r.offline) { s.stop(r.ok ? "Key works" : `${r.error}; saved anyway`); ek = entered; }
114
+ else { s.error(r.error); if (!bail(await p.confirm({ message: "Try another key?", initialValue: true }))) { cfg.TTS_DEFAULT = "kokoro"; cfg.KOKORO = "on"; break; } }
115
+ }
116
+ if (ek) {
117
+ cfg.ELEVENLABS_API_KEY = ek;
118
+ const vid = bail(await p.text({ message: "ElevenLabs voice ID (Enter for the default voice, Matilda)", placeholder: "XrExE9yKIg1WjnnlVkGX", defaultValue: prev.ELEVEN_VOICE_ID || "" }));
119
+ if (vid) cfg.ELEVEN_VOICE_ID = vid.trim(); else delete cfg.ELEVEN_VOICE_ID;
120
+ }
121
+ }
122
+
123
+ // 4 · speech recognition models
124
+ const have = installedPacks(MODELS_DIR);
125
+ const packs = bail(await p.multiselect({
126
+ message: "Download offline speech recognition? (space to toggle, enter to confirm)",
127
+ required: false,
128
+ initialValues: have.length ? have : ["english-small"],
129
+ options: Object.entries(MODEL_PACKS).map(([id, m]) => ({ value: id, label: `${m.label} · ${m.size}`, hint: have.includes(id) ? "installed" : m.hint })),
130
+ }));
131
+ const missing = packs.filter((x) => !have.includes(x));
132
+ if (missing.length) await downloadPacks(missing);
133
+ else if (!packs.length) p.log.info("No models: Ava will use the browser's speech recognition (Chrome).");
134
+ }
135
+
136
+ // 5 · who can reach it
137
+ const audience = bail(await p.select({
138
+ message: "Who will use this Ava?",
139
+ initialValue: prev.HOST === "127.0.0.1" || !prev.ACCESS_PASSWORD ? "me" : "others",
140
+ options: [
141
+ { value: "me", label: "Just me, on this computer", hint: "safest; reachable only at localhost" },
142
+ { value: "others", label: "My team or customers", hint: "over a network or the internet; needs a password" },
143
+ ],
144
+ }));
145
+ if (audience === "me") {
146
+ cfg.HOST = "127.0.0.1";
147
+ delete cfg.ACCESS_PASSWORD; delete cfg.TRUST_PROXY;
148
+ } else {
149
+ delete cfg.HOST;
150
+ const suggested = suggestPassword();
151
+ const how = bail(await p.select({
152
+ message: "Set an access password (every call spends your API credit)",
153
+ options: [
154
+ ...(prev.ACCESS_PASSWORD ? [{ value: "keep", label: "Keep the current password" }] : []),
155
+ { value: "gen", label: `Use a generated one: ${suggested}`, hint: "recommended; it's saved in the config file" },
156
+ { value: "own", label: "Choose my own" },
157
+ ],
158
+ }));
159
+ if (how === "gen") cfg.ACCESS_PASSWORD = suggested;
160
+ if (how === "own") cfg.ACCESS_PASSWORD = bail(await p.password({ message: "Access password (at least 10 characters)", validate: (v) => (!v || v.length < 10 ? "Use at least 10 characters" : undefined) }));
161
+ const proxied = bail(await p.confirm({ message: "Will it run behind a proxy or host (Fly.io, Cloudflare, nginx, Render…)?", initialValue: prev.TRUST_PROXY === "1" }));
162
+ if (proxied) cfg.TRUST_PROXY = "1"; else delete cfg.TRUST_PROXY;
163
+ }
164
+
165
+ // 6 · port
166
+ const portStr = bail(await p.text({
167
+ message: "Port",
168
+ defaultValue: String(prev.PORT || DEFAULT_PORT),
169
+ placeholder: String(prev.PORT || DEFAULT_PORT),
170
+ 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"; },
171
+ }));
172
+ cfg.PORT = String(Number(portStr) || DEFAULT_PORT);
173
+ if (!(await portFree(Number(cfg.PORT), cfg.HOST))) p.log.warn(`Port ${cfg.PORT} is in use right now. Stop that program, or run \`ava-engine start --port <other>\`.`);
174
+
175
+ // 7 · save
176
+ cfg.AVA_MODELS_DIR = MODELS_DIR;
177
+ writeConfig(cfg);
178
+ p.note(
179
+ [
180
+ `AI replies ${cfg.OPENROUTER_API_KEY ? `OpenRouter (${mask(cfg.OPENROUTER_API_KEY)})` : "MOCK mode (canned replies)"}`,
181
+ `Voice ${{ kokoro: "Kokoro (offline)", elevenlabs: "ElevenLabs", browser: "the browser's voice" }[cfg.TTS_DEFAULT] || "Kokoro (offline)"}`,
182
+ `Speech models ${installedPacks(MODELS_DIR).map((x) => MODEL_PACKS[x].label).join(", ") || "none (browser speech recognition)"}`,
183
+ `Access ${cfg.HOST === "127.0.0.1" ? "this computer only" : `password protected${cfg.ACCESS_PASSWORD ? ` (${cfg.ACCESS_PASSWORD})` : ""}`}`,
184
+ `Address http://localhost:${cfg.PORT}/app`,
185
+ "",
186
+ `Saved to ${CONFIG_FILE}`,
187
+ ].join("\n"),
188
+ "Your Ava",
189
+ );
190
+
191
+ const start = bail(await p.confirm({ message: "Start Ava now?", initialValue: true }));
192
+ p.outro(start ? "Starting Ava…" : "All set. Start Ava any time with: npx ava-engine");
193
+ return { start, port: Number(cfg.PORT) };
194
+ }
195
+
196
+ /**
197
+ * Non-interactive setup for scripts, servers and CI: settings come from the
198
+ * environment and flags, with safe defaults. Returns the written config.
199
+ */
200
+ export async function runSetupNonInteractive({ models = [], port } = {}) {
201
+ const prev = readConfig() || {};
202
+ const env = process.env;
203
+ const pick = (k) => env[k] ?? prev[k];
204
+ const cfg = { ...prev };
205
+ 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);
206
+ cfg.PORT = String(port || pick("PORT") || DEFAULT_PORT);
207
+ if (!cfg.ACCESS_PASSWORD) cfg.HOST = pick("HOST") || "127.0.0.1"; // never expose an open server by default
208
+ else if (pick("HOST")) cfg.HOST = pick("HOST");
209
+ cfg.AVA_MODELS_DIR = MODELS_DIR;
210
+ writeConfig(cfg);
211
+ for (const pack of models) {
212
+ console.log(`↓ ${MODEL_PACKS[pack]?.label || pack}`);
213
+ await fetchPack(pack, MODELS_DIR);
214
+ }
215
+ console.log(`Saved ${CONFIG_FILE}${cfg.OPENROUTER_API_KEY ? "" : " (MOCK mode: no OPENROUTER_API_KEY)"}`);
216
+ return cfg;
217
+ }
218
+
219
+ export { AVA_HOME };