ava-engine 0.1.0 → 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.
package/lib/cli/setup.mjs CHANGED
@@ -1,12 +1,15 @@
1
- // `ava-engine setup`: the guided first-run wizard (and its non-interactive twin).
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.
2
3
  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 { CONFIG_FILE, MODELS_DIR, DEFAULT_PORT, readConfig, writeConfig, checkOpenRouterKey, checkElevenLabsKey, portFree, suggestPassword } from "./config.mjs";
5
+ import { buildConfig, VOICES } from "./apply.mjs";
4
6
  import { MODEL_PACKS, installedPacks, fetchPack } from "../fetch-models.mjs";
7
+ import { loadKokoro, kokoroInstalled, KOKORO_SIZE } from "../kokoro-model.mjs";
5
8
 
6
9
  const mask = (v) => (v ? `${v.slice(0, 6)}…${v.slice(-4)}` : "");
7
10
  function bail(v) { if (p.isCancel(v)) { p.cancel("Setup cancelled. Nothing was changed."); process.exit(0); } return v; }
8
11
 
9
- /** Download model packs with a progress bar per file. */
12
+ /** Download speech model packs with a progress bar per pack. */
10
13
  export async function downloadPacks(packs) {
11
14
  for (const pack of packs) {
12
15
  const spec = MODEL_PACKS[pack];
@@ -24,18 +27,37 @@ export async function downloadPacks(packs) {
24
27
  if (bar) bar.stop(`${spec.label} ready`); else p.log.success(`${spec.label} already installed`);
25
28
  } catch (e) {
26
29
  if (bar) bar.error(`${spec.label} failed`);
27
- p.log.error(`${e.message}\nYou can retry later with: ava-engine models ${pack}`);
30
+ p.log.error(`${e.message}\nYou can retry later with: npx ava-engine models ${pack}`);
28
31
  }
29
32
  }
30
33
  }
31
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
+
32
53
  /**
33
54
  * The interactive wizard. Returns { start: boolean, port }.
34
55
  * Existing settings are kept unless you change them.
35
56
  */
36
57
  export async function runSetup() {
58
+ process.env.AVA_MODELS_DIR ??= MODELS_DIR; // so "is Kokoro installed?" looks in ~/.ava-engine/models
37
59
  const prev = readConfig() || {};
38
- const cfg = { ...prev };
60
+ const answers = {};
39
61
 
40
62
  console.log("");
41
63
  p.intro(" ΛVΛ Ava Engine setup ");
@@ -43,29 +65,24 @@ export async function runSetup() {
43
65
  "A screen-aware voice agent: it joins a call, sees the tab your\n" +
44
66
  "customer shares, and talks them through it.\n\n" +
45
67
  "This takes about 2 minutes. You can change anything later with\n" +
46
- "`ava-engine setup`, or by editing " + CONFIG_FILE,
68
+ "`npx ava-engine setup`, or by editing " + CONFIG_FILE,
47
69
  "Welcome",
48
70
  );
49
71
 
50
- // 1 · how to start
72
+ // 1 · AI replies
51
73
  const mode = bail(await p.select({
52
- message: "How do you want to start?",
74
+ message: "How should Ava think?",
53
75
  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" },
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" },
56
78
  ],
57
79
  }));
58
-
59
- // 2 · language models
60
80
  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.");
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`.");
63
83
  } else {
64
84
  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
- }
85
+ if (key && !bail(await p.confirm({ message: `Keep your current OpenRouter key (${mask(key)})?`, initialValue: true }))) key = undefined;
69
86
  while (!key) {
70
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).");
71
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,96 +106,88 @@ export async function runSetup() {
89
106
  if (next === "mock") break;
90
107
  }
91
108
  }
92
- if (key) cfg.OPENROUTER_API_KEY = key; else delete cfg.OPENROUTER_API_KEY;
109
+ answers.openrouterKey = key || null;
110
+ }
93
111
 
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;
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; }
120
130
  }
121
131
  }
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).");
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
+ }
134
136
  }
135
137
 
136
- // 5 · who can reach it
137
- const audience = bail(await p.select({
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({
138
152
  message: "Who will use this Ava?",
139
- initialValue: prev.HOST === "127.0.0.1" || !prev.ACCESS_PASSWORD ? "me" : "others",
153
+ initialValue: prev.ACCESS_PASSWORD ? "others" : "me",
140
154
  options: [
141
155
  { value: "me", label: "Just me, on this computer", hint: "safest; reachable only at localhost" },
142
156
  { value: "others", label: "My team or customers", hint: "over a network or the internet; needs a password" },
143
157
  ],
144
158
  }));
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;
159
+ if (answers.audience === "others") {
150
160
  const suggested = suggestPassword();
151
161
  const how = bail(await p.select({
152
162
  message: "Set an access password (every call spends your API credit)",
153
163
  options: [
154
164
  ...(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" },
165
+ { value: "gen", label: `Use a generated one: ${suggested}`, hint: "recommended; it's saved in the settings file" },
156
166
  { value: "own", label: "Choose my own" },
157
167
  ],
158
168
  }));
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;
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" }));
163
172
  }
164
173
 
165
- // 6 · port
174
+ // 5 · port
166
175
  const portStr = bail(await p.text({
167
176
  message: "Port",
168
177
  defaultValue: String(prev.PORT || DEFAULT_PORT),
169
178
  placeholder: String(prev.PORT || DEFAULT_PORT),
170
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"; },
171
180
  }));
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>\`.`);
181
+ answers.port = Number(portStr) || DEFAULT_PORT;
174
182
 
175
- // 7 · save
176
- cfg.AVA_MODELS_DIR = MODELS_DIR;
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>\`.`);
177
186
  writeConfig(cfg);
178
187
  p.note(
179
188
  [
180
189
  `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)"}`,
190
+ `Voice ${VOICES[cfg.TTS_DEFAULT]?.label || "Kokoro"}`,
182
191
  `Speech models ${installedPacks(MODELS_DIR).map((x) => MODEL_PACKS[x].label).join(", ") || "none (browser speech recognition)"}`,
183
192
  `Access ${cfg.HOST === "127.0.0.1" ? "this computer only" : `password protected${cfg.ACCESS_PASSWORD ? ` (${cfg.ACCESS_PASSWORD})` : ""}`}`,
184
193
  `Address http://localhost:${cfg.PORT}/app`,
@@ -209,11 +218,16 @@ export async function runSetupNonInteractive({ models = [], port } = {}) {
209
218
  cfg.AVA_MODELS_DIR = MODELS_DIR;
210
219
  writeConfig(cfg);
211
220
  for (const pack of models) {
212
- console.log(`↓ ${MODEL_PACKS[pack]?.label || pack}`);
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}`);
213
229
  await fetchPack(pack, MODELS_DIR);
214
230
  }
215
231
  console.log(`Saved ${CONFIG_FILE}${cfg.OPENROUTER_API_KEY ? "" : " (MOCK mode: no OPENROUTER_API_KEY)"}`);
216
232
  return cfg;
217
233
  }
218
-
219
- export { AVA_HOME };
@@ -0,0 +1,51 @@
1
+ // The offline Kokoro voice model: where it's cached, whether it's there, and
2
+ // loading it with download progress. Shared by the server and the setup wizard.
3
+ //
4
+ // It's cached under the models folder (~/.ava-engine/models/kokoro for the CLI),
5
+ // not inside node_modules, so it survives `npx` updates instead of downloading
6
+ // again after every new version.
7
+
8
+ import { existsSync, readdirSync } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ export const KOKORO_MODEL = process.env.KOKORO_MODEL || "onnx-community/Kokoro-82M-v1.0-ONNX";
13
+ export const KOKORO_DTYPE = process.env.KOKORO_DTYPE || "q8";
14
+ export const KOKORO_SIZE = "~90 MB";
15
+
16
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
17
+ export function kokoroCacheDir() {
18
+ return process.env.KOKORO_CACHE_DIR || join(process.env.AVA_MODELS_DIR || join(ROOT, "models"), "kokoro");
19
+ }
20
+
21
+ /** True once the model's weights are on disk (no network needed to start). */
22
+ export function kokoroInstalled(dir = kokoroCacheDir()) {
23
+ const onnx = join(dir, ...KOKORO_MODEL.split("/"), "onnx");
24
+ try { return existsSync(onnx) && readdirSync(onnx).some((f) => f.endsWith(".onnx")); } catch { return false; }
25
+ }
26
+
27
+ /**
28
+ * Load Kokoro, downloading it first if needed.
29
+ * onProgress({ loaded, total, pct }) reports the overall download.
30
+ */
31
+ export async function loadKokoro({ onProgress } = {}) {
32
+ const { env } = await import("@huggingface/transformers");
33
+ env.cacheDir = kokoroCacheDir();
34
+ const { KokoroTTS } = await import("kokoro-js");
35
+ const files = new Map(); // file → { loaded, total }
36
+ // Count only the large files (the weights). The small config/tokenizer files
37
+ // finish first and would make the bar jump to 100% and back.
38
+ const progress_callback = (e) => {
39
+ if (!onProgress || e.status !== "progress" || !(e.total > 1_000_000)) return;
40
+ files.set(e.file, { loaded: e.loaded, total: e.total });
41
+ let loaded = 0, total = 0;
42
+ for (const f of files.values()) { loaded += f.loaded; total += f.total; }
43
+ onProgress({ loaded, total, pct: Math.min(100, Math.floor((loaded / total) * 100)) });
44
+ };
45
+ // transformers.js warns about a missing content-length on every download; it's
46
+ // harmless and would break the setup wizard's progress bar, so hide just that line.
47
+ const warn = console.warn;
48
+ console.warn = (...args) => { if (!String(args[0]).includes("content-length")) warn(...args); };
49
+ try { return await KokoroTTS.from_pretrained(KOKORO_MODEL, { dtype: KOKORO_DTYPE, device: "cpu", progress_callback }); }
50
+ finally { console.warn = warn; }
51
+ }
package/lib/tts.mjs CHANGED
@@ -3,6 +3,8 @@
3
3
  // audio — works on the oldest device. ElevenLabs/Hume are cloud (paid). "browser"
4
4
  // is the on-device Web Speech fallback.
5
5
 
6
+ import { loadKokoro, kokoroInstalled } from "./kokoro-model.mjs";
7
+
6
8
  const HAS_ELEVEN = !!process.env.ELEVENLABS_API_KEY;
7
9
  const HAS_HUME = !!process.env.HUME_API_KEY;
8
10
  const HAS_KOKORO = process.env.KOKORO !== "off"; // local, free — on by default
@@ -13,9 +15,7 @@ const ELEVEN_LABEL = process.env.ELEVEN_LABEL || "Matilda";
13
15
  const HUME_VOICE = process.env.HUME_VOICE_ID || "a7ecc00a-6fc0-4546-8126-e12cfd8de3bf";
14
16
  const HUME_LABEL = process.env.HUME_LABEL || "Aria";
15
17
  const HUME_DESC = process.env.HUME_DESCRIPTION || "Warm, empathetic, natural and reassuring";
16
- const KOKORO_MODEL = process.env.KOKORO_MODEL || "onnx-community/Kokoro-82M-v1.0-ONNX";
17
18
  const KOKORO_VOICE = process.env.KOKORO_VOICE || "af_heart";
18
- const KOKORO_DTYPE = process.env.KOKORO_DTYPE || "q8";
19
19
 
20
20
  export const TTS_ENABLED = HAS_KOKORO || HAS_ELEVEN || HAS_HUME;
21
21
  const FETCH_TIMEOUT_MS = 20_000;
@@ -50,8 +50,8 @@ let kokoroTTS = null, kokoroInit = null;
50
50
  async function initKokoro() {
51
51
  if (kokoroTTS) return kokoroTTS;
52
52
  if (!kokoroInit) kokoroInit = (async () => {
53
- const { KokoroTTS } = await import("kokoro-js");
54
- kokoroTTS = await KokoroTTS.from_pretrained(KOKORO_MODEL, { dtype: KOKORO_DTYPE, device: "cpu" });
53
+ if (!kokoroInstalled()) console.log(" kokoro: downloading the voice model (~90 MB, first run only)…");
54
+ kokoroTTS = await loadKokoro();
55
55
  return kokoroTTS;
56
56
  })();
57
57
  return kokoroInit;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ava-engine",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "A screen-aware voice agent: sees the tab a user shares, talks them through it, and hands off to a human when needed.",
6
6
  "keywords": [
@@ -45,16 +45,20 @@
45
45
  "lint": "eslint .",
46
46
  "test:all": "npm run lint && npm run check && npm test && npm run test:e2e",
47
47
  "demo:record": "node scripts/record-demo.mjs",
48
- "cli": "node bin/ava-engine.mjs"
48
+ "cli": "node bin/ava-engine.mjs",
49
+ "prepare": "node -e \"if(require('fs').existsSync('.git')&&require('fs').existsSync('.githooks'))try{require('child_process').execFileSync('git',['config','core.hooksPath','.githooks'],{stdio:'ignore'})}catch{}\""
49
50
  },
50
51
  "dependencies": {
51
52
  "@clack/prompts": "1.8.1",
53
+ "@huggingface/transformers": "3.8.1",
52
54
  "kokoro-js": "^1.2.1",
53
55
  "sherpa-onnx-node": "^1.13.8",
54
56
  "ws": "^8.21.3"
55
57
  },
56
58
  "devDependencies": {
57
59
  "@axe-core/playwright": "^4.13.0",
60
+ "@commitlint/cli": "21.2.4",
61
+ "@commitlint/config-conventional": "21.2.4",
58
62
  "eslint": "^9.39.5",
59
63
  "globals": "^15.15.0",
60
64
  "playwright": "1.61"
@@ -70,5 +74,9 @@
70
74
  "homepage": "https://github.com/Mr-vero/ava-engine#readme",
71
75
  "bugs": {
72
76
  "url": "https://github.com/Mr-vero/ava-engine/issues"
77
+ },
78
+ "publishConfig": {
79
+ "access": "public",
80
+ "provenance": true
73
81
  }
74
82
  }
@@ -0,0 +1,94 @@
1
+ @font-face { font-family: "Inter Tight"; src: url(/fonts/inter-tight-var.woff2) format("woff2"); font-weight: 100 900; font-display: swap; }
2
+ @font-face { font-family: "Inter"; src: url(/fonts/inter-var.woff2) format("woff2"); font-weight: 100 900; font-display: swap; }
3
+ @font-face { font-family: "Plex Mono"; src: url(/fonts/plex-mono-400.woff2) format("woff2"); font-weight: 400; font-display: swap; }
4
+
5
+ :root {
6
+ --ink: #0A0A0C; --ink2: #4A4B55; --meta: #686975; --line: #E6E6EB; --soft: #F5F5F7; --card: #FFFFFF;
7
+ --accent: #4F46E5; --accent-tint: rgba(79,70,229,0.08); --ok: #15803D; --ok-tint: #ECFDF3; --err: #B42318; --err-tint: #FEF3F2;
8
+ --radius: 14px;
9
+ }
10
+ * { box-sizing: border-box; }
11
+ [hidden] { display: none !important; } /* author display rules (e.g. .field) must not un-hide things */
12
+ html, body { margin: 0; background: var(--soft); color: var(--ink); font: 16px/1.5 Inter, -apple-system, "Segoe UI", sans-serif; }
13
+ a { color: var(--accent); }
14
+ code { font: 13px "Plex Mono", ui-monospace, monospace; background: var(--soft); padding: 1px 5px; border-radius: 5px; }
15
+
16
+ .shell { display: grid; grid-template-columns: 300px 1fr; min-height: 100vh; min-height: 100dvh; }
17
+ .rail { background: var(--ink); color: #fff; padding: 36px 30px; display: flex; flex-direction: column; gap: 36px; }
18
+ .brand { display: flex; align-items: center; gap: 12px; font: 600 20px "Inter Tight", sans-serif; }
19
+ .brand svg { width: 34px; height: 34px; }
20
+ .brand svg rect { fill: #fff; } .brand svg path { stroke: #0A0A0C; }
21
+ .steps { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; counter-reset: s; }
22
+ .steps li { counter-increment: s; display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 10px; color: #9D9DAB; font-weight: 500; }
23
+ .steps li::before { content: counter(s); width: 26px; height: 26px; flex: none; display: grid; place-items: center; border-radius: 50%; border: 1.5px solid #3A3A46; font: 500 13px "Plex Mono", monospace; }
24
+ .steps li.done { color: #D6D6E0; } .steps li.done::before { content: "✓"; background: #2B2B36; border-color: #2B2B36; }
25
+ .steps li.current { color: #fff; background: #1C1C24; } .steps li.current::before { border-color: #A5A0FF; color: #A5A0FF; }
26
+ .rail-note { margin-top: auto; font-size: 13px; color: #9D9DAB; }
27
+ .rail-note code { background: #1C1C24; color: #D6D6E0; }
28
+
29
+ .panel { padding: 56px clamp(20px, 6vw, 88px); max-width: 860px; width: 100%; }
30
+ .eyebrow { margin: 0 0 8px; font: 500 13px "Plex Mono", monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--accent); }
31
+ h1 { margin: 0 0 12px; font: 600 clamp(28px, 4vw, 40px)/1.1 "Inter Tight", sans-serif; letter-spacing: -0.025em; }
32
+ .lead { margin: 0 0 28px; color: var(--ink2); font-size: 17px; max-width: 640px; }
33
+
34
+ .cards { display: grid; gap: 12px; margin-bottom: 24px; }
35
+ .card { position: relative; display: flex; gap: 14px; align-items: flex-start; padding: 18px 20px; background: var(--card); border: 1.5px solid var(--line); border-radius: var(--radius); cursor: pointer; transition: border-color .15s, box-shadow .15s; }
36
+ .card:hover { border-color: #CFCFD8; }
37
+ .card:has(input:checked) { border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-tint); }
38
+ .card:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 3px; }
39
+ .card input { margin: 4px 0 0; width: 18px; height: 18px; accent-color: var(--accent); flex: none; }
40
+ .card-body { display: flex; flex-direction: column; gap: 3px; flex: 1; }
41
+ .card-body b { font: 600 17px "Inter Tight", sans-serif; }
42
+ .card-body span { color: var(--ink2); font-size: 14.5px; }
43
+ .card:has(.badge) .card-body { padding-right: 84px; }
44
+ .badge { position: absolute; top: 16px; right: 18px; font: 500 12px "Plex Mono", monospace; color: var(--ok); background: var(--ok-tint); padding: 2px 8px; border-radius: 99px; }
45
+
46
+ .field { display: flex; flex-direction: column; gap: 8px; margin-bottom: 24px; }
47
+ .field label { font-weight: 600; font-size: 15px; }
48
+ .opt { font-weight: 400; color: var(--meta); }
49
+ .row { display: flex; gap: 8px; flex-wrap: wrap; }
50
+ input[type=text], input[type=password], input[type=number] { flex: 1; min-width: 200px; height: 46px; padding: 0 14px; border: 1.5px solid var(--line); border-radius: 10px; font: 15px "Plex Mono", monospace; background: #fff; color: var(--ink); }
51
+ input:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-tint); }
52
+ input.short { max-width: 160px; flex: none; }
53
+ .hint { margin: 0; color: var(--meta); font-size: 14px; }
54
+ .keep label, .check { font-weight: 400 !important; display: flex; gap: 10px; align-items: center; font-size: 15px; }
55
+ .keep input, .check input { width: 18px; height: 18px; accent-color: var(--accent); }
56
+
57
+ .status { margin: 0; min-height: 22px; font-size: 14.5px; }
58
+ .status.ok { color: var(--ok); } .status.err { color: var(--err); }
59
+ .banner { padding: 14px 18px; border-radius: 12px; margin-bottom: 24px; }
60
+ .banner.error { background: var(--err-tint); color: var(--err); }
61
+
62
+ .job { background: #fff; border: 1.5px solid var(--line); border-radius: 12px; padding: 14px 16px; margin-bottom: 12px; }
63
+ .job-head { display: flex; justify-content: space-between; font-size: 14.5px; margin-bottom: 10px; font-weight: 500; }
64
+ .job-head span:last-child { font-family: "Plex Mono", monospace; color: var(--meta); }
65
+ .bar { height: 8px; background: var(--soft); border-radius: 99px; overflow: hidden; }
66
+ .bar i { display: block; height: 100%; width: 0; background: var(--accent); border-radius: 99px; transition: width .3s; }
67
+ .job.done .bar i { background: var(--ok); } .job.error .bar i { background: var(--err); }
68
+
69
+ .summary { display: grid; grid-template-columns: max-content 1fr; gap: 14px 28px; background: #fff; border: 1.5px solid var(--line); border-radius: var(--radius); padding: 22px 24px; margin: 0 0 20px; }
70
+ .summary dt { color: var(--meta); font-size: 14.5px; } .summary dd { margin: 0; font-weight: 500; word-break: break-word; }
71
+
72
+ .nav { display: flex; justify-content: space-between; align-items: center; margin-top: 32px; gap: 12px; }
73
+ .btn { height: 46px; padding: 0 22px; border-radius: 99px; font: 600 15px "Inter Tight", sans-serif; cursor: pointer; border: 1.5px solid transparent; }
74
+ .btn.primary { background: var(--ink); color: #fff; }
75
+ .btn.primary:hover { background: #24242C; }
76
+ .btn.primary:disabled { background: #B9B9C3; cursor: not-allowed; }
77
+ .btn.ghost { background: #fff; color: var(--ink); border-color: var(--line); }
78
+ .btn.ghost:hover { border-color: #CFCFD8; }
79
+ .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
80
+
81
+ .launching { text-align: center; padding-top: 12vh; }
82
+ .launching .lead { margin-inline: auto; }
83
+ .orb { width: 120px; height: 120px; margin: 0 auto 36px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #C9C4FF 0%, #8B7CFF 28%, #5B4FF0 60%, #3B2FC4 100%); box-shadow: 0 0 80px rgba(124,108,246,.55); animation: pulse 1.6s ease-in-out infinite; }
84
+ @keyframes pulse { 50% { transform: scale(1.06); box-shadow: 0 0 120px rgba(124,108,246,.75); } }
85
+ @media (prefers-reduced-motion: reduce) { .orb { animation: none; } .bar i { transition: none; } }
86
+
87
+ @media (max-width: 760px) {
88
+ .shell { grid-template-columns: 1fr; }
89
+ .rail { padding: 20px; gap: 16px; }
90
+ .steps { flex-direction: row; overflow-x: auto; gap: 4px; }
91
+ .steps li { padding: 6px 8px; font-size: 0; gap: 0; } .steps li.current { font-size: 14px; gap: 8px; }
92
+ .rail-note { display: none; }
93
+ .panel { padding: 32px 16px; }
94
+ }
@@ -0,0 +1,129 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Set up Ava</title>
7
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
8
+ <link rel="stylesheet" href="/setup.css">
9
+ <script src="/setup.js" defer></script>
10
+ </head>
11
+ <body>
12
+ <div class="shell">
13
+ <aside class="rail" aria-label="Setup progress">
14
+ <div class="brand">
15
+ <svg viewBox="0 0 24 24" aria-hidden="true"><rect width="24" height="24" rx="6" fill="#0A0A0C"/><path transform="translate(2.67 8.4) scale(.3)" d="M0 24L9.5 0L19 24M21.6 0L31.1 24L40.6 0M43.2 24L52.7 0L62.2 24" stroke="#fff" fill="none" stroke-width="3.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
16
+ <span>Ava Engine</span>
17
+ </div>
18
+ <ol class="steps" id="steps">
19
+ <li data-step="ai">AI replies</li>
20
+ <li data-step="voice">Voice</li>
21
+ <li data-step="speech">Speech recognition</li>
22
+ <li data-step="access">Access</li>
23
+ <li data-step="review">Launch</li>
24
+ </ol>
25
+ <p class="rail-note">Settings are saved on this computer only, in <code id="configFile">~/.ava-engine/.env</code>.</p>
26
+ </aside>
27
+
28
+ <main class="panel">
29
+ <div id="fatal" class="banner error" role="alert" hidden></div>
30
+
31
+ <!-- 1 · AI -->
32
+ <section class="step" data-step="ai" aria-labelledby="h-ai">
33
+ <p class="eyebrow">Step 1 of 5</p>
34
+ <h1 id="h-ai">How should Ava think?</h1>
35
+ <p class="lead">Ava's AI runs through <a href="https://openrouter.ai" target="_blank" rel="noopener">OpenRouter</a>: one key for every model. You can also look around first with canned replies.</p>
36
+ <div class="cards" role="radiogroup" aria-label="AI mode">
37
+ <label class="card"><input type="radio" name="mode" value="full" checked><span class="card-body"><b>With real AI</b><span>Recommended. Needs an OpenRouter key.</span></span></label>
38
+ <label class="card"><input type="radio" name="mode" value="mock"><span class="card-body"><b>Just look around first</b><span>No key. Canned replies; voice, screen sharing and everything else work.</span></span></label>
39
+ </div>
40
+ <div class="field" id="keyField">
41
+ <div id="keepKeyRow" class="keep" hidden><label><input type="checkbox" id="keepKey" checked> Keep my current key <code id="currentKey"></code></label></div>
42
+ <div id="newKeyRow">
43
+ <label for="orKey">OpenRouter API key</label>
44
+ <div class="row">
45
+ <input id="orKey" type="password" autocomplete="off" spellcheck="false" placeholder="sk-or-v1-…">
46
+ <button type="button" class="btn ghost" id="checkKey">Check key</button>
47
+ </div>
48
+ <p class="hint">Create one at <a href="https://openrouter.ai/keys" target="_blank" rel="noopener">openrouter.ai/keys</a>, and set a credit limit on it.</p>
49
+ <p class="status" id="keyStatus" role="status" aria-live="polite"></p>
50
+ </div>
51
+ </div>
52
+ <div class="nav"><span></span><button type="button" class="btn primary" data-next>Continue</button></div>
53
+ </section>
54
+
55
+ <!-- 2 · Voice -->
56
+ <section class="step" data-step="voice" aria-labelledby="h-voice" hidden>
57
+ <p class="eyebrow">Step 2 of 5</p>
58
+ <h1 id="h-voice">Which voice should Ava speak with?</h1>
59
+ <div class="cards" id="voiceCards" role="radiogroup" aria-label="Voice"></div>
60
+ <div class="job" id="kokoroJob" hidden>
61
+ <div class="job-head"><span id="kokoroLabel">Downloading the Kokoro voice</span><span id="kokoroPct">0%</span></div>
62
+ <div class="bar"><i id="kokoroBar"></i></div>
63
+ </div>
64
+ <div class="field" id="elevenField" hidden>
65
+ <div id="keepElevenRow" class="keep" hidden><label><input type="checkbox" id="keepEleven" checked> Keep my current ElevenLabs key <code id="currentEleven"></code></label></div>
66
+ <div id="newElevenRow">
67
+ <label for="elKey">ElevenLabs API key</label>
68
+ <div class="row"><input id="elKey" type="password" autocomplete="off" spellcheck="false"><button type="button" class="btn ghost" id="checkEleven">Check key</button></div>
69
+ <p class="status" id="elevenStatus" role="status" aria-live="polite"></p>
70
+ </div>
71
+ <label for="elVoice">Voice ID <span class="opt">optional, defaults to Matilda</span></label>
72
+ <input id="elVoice" type="text" autocomplete="off" spellcheck="false" placeholder="XrExE9yKIg1WjnnlVkGX">
73
+ </div>
74
+ <div class="nav"><button type="button" class="btn ghost" data-back>Back</button><button type="button" class="btn primary" data-next>Continue</button></div>
75
+ </section>
76
+
77
+ <!-- 3 · Speech -->
78
+ <section class="step" data-step="speech" aria-labelledby="h-speech" hidden>
79
+ <p class="eyebrow">Step 3 of 5</p>
80
+ <h1 id="h-speech">Offline speech recognition</h1>
81
+ <p class="lead">Turns your customers' speech into text on this computer, free. Skip it to use Chrome's built-in recognition instead.</p>
82
+ <div class="cards" id="packCards" aria-label="Speech models"></div>
83
+ <div id="packJobs"></div>
84
+ <div class="nav"><button type="button" class="btn ghost" data-back>Back</button><button type="button" class="btn primary" data-next>Continue</button></div>
85
+ </section>
86
+
87
+ <!-- 4 · Access -->
88
+ <section class="step" data-step="access" aria-labelledby="h-access" hidden>
89
+ <p class="eyebrow">Step 4 of 5</p>
90
+ <h1 id="h-access">Who will use this Ava?</h1>
91
+ <div class="cards" role="radiogroup" aria-label="Who will use Ava">
92
+ <label class="card"><input type="radio" name="audience" value="me" checked><span class="card-body"><b>Just me, on this computer</b><span>Safest. Reachable only at localhost.</span></span></label>
93
+ <label class="card"><input type="radio" name="audience" value="others"><span class="card-body"><b>My team or customers</b><span>Over a network or the internet. Needs a password, because every call spends your API credit.</span></span></label>
94
+ </div>
95
+ <div class="field" id="passwordField" hidden>
96
+ <div id="keepPassRow" class="keep" hidden><label><input type="checkbox" id="keepPass" checked> Keep my current password</label></div>
97
+ <div id="newPassRow">
98
+ <label for="password">Access password</label>
99
+ <div class="row"><input id="password" type="text" autocomplete="new-password" spellcheck="false" minlength="10"><button type="button" class="btn ghost" id="regen">New one</button><button type="button" class="btn ghost" id="copyPass">Copy</button></div>
100
+ <p class="hint">At least 10 characters. You'll type it when you open Ava.</p>
101
+ </div>
102
+ <label class="check"><input type="checkbox" id="proxy"> It will run behind a proxy or host (Fly.io, Cloudflare, nginx, Render…)</label>
103
+ </div>
104
+ <div class="field">
105
+ <label for="port">Port</label>
106
+ <input id="port" type="number" min="1" max="65535" inputmode="numeric" class="short">
107
+ </div>
108
+ <div class="nav"><button type="button" class="btn ghost" data-back>Back</button><button type="button" class="btn primary" data-next>Continue</button></div>
109
+ </section>
110
+
111
+ <!-- 5 · Review -->
112
+ <section class="step" data-step="review" aria-labelledby="h-review" hidden>
113
+ <p class="eyebrow">Step 5 of 5</p>
114
+ <h1 id="h-review">Ready to launch</h1>
115
+ <dl class="summary" id="summary"></dl>
116
+ <p class="status" id="launchStatus" role="status" aria-live="polite"></p>
117
+ <div class="nav"><button type="button" class="btn ghost" data-back>Back</button><button type="button" class="btn primary" id="launch">Launch Ava</button></div>
118
+ </section>
119
+
120
+ <!-- launching -->
121
+ <section class="step launching" data-step="launching" aria-live="polite" hidden>
122
+ <div class="orb" aria-hidden="true"></div>
123
+ <h1>Starting Ava…</h1>
124
+ <p class="lead" id="launchMsg">This takes a few seconds. The app opens here when it's ready.</p>
125
+ </section>
126
+ </main>
127
+ </div>
128
+ </body>
129
+ </html>