ava-engine 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,135 @@
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
+ <div class="connect">
44
+ <button type="button" class="btn primary" id="connectOr">Connect OpenRouter</button>
45
+ <p class="hint">Sign in to OpenRouter and approve, and Ava gets its own key. Nothing to copy.</p>
46
+ <p class="status" id="connectStatus" role="status" aria-live="polite"></p>
47
+ </div>
48
+ <p class="or"><span>or paste a key</span></p>
49
+ <label for="orKey">OpenRouter API key</label>
50
+ <div class="row">
51
+ <input id="orKey" type="password" autocomplete="off" spellcheck="false" placeholder="sk-or-v1-…">
52
+ <button type="button" class="btn ghost" id="checkKey">Check key</button>
53
+ </div>
54
+ <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>
55
+ <p class="status" id="keyStatus" role="status" aria-live="polite"></p>
56
+ </div>
57
+ </div>
58
+ <div class="nav"><span></span><button type="button" class="btn primary" data-next>Continue</button></div>
59
+ </section>
60
+
61
+ <!-- 2 · Voice -->
62
+ <section class="step" data-step="voice" aria-labelledby="h-voice" hidden>
63
+ <p class="eyebrow">Step 2 of 5</p>
64
+ <h1 id="h-voice">Which voice should Ava speak with?</h1>
65
+ <div class="cards" id="voiceCards" role="radiogroup" aria-label="Voice"></div>
66
+ <div class="job" id="kokoroJob" hidden>
67
+ <div class="job-head"><span id="kokoroLabel">Downloading the Kokoro voice</span><span id="kokoroPct">0%</span></div>
68
+ <div class="bar"><i id="kokoroBar"></i></div>
69
+ </div>
70
+ <div class="field" id="elevenField" hidden>
71
+ <div id="keepElevenRow" class="keep" hidden><label><input type="checkbox" id="keepEleven" checked> Keep my current ElevenLabs key <code id="currentEleven"></code></label></div>
72
+ <div id="newElevenRow">
73
+ <label for="elKey">ElevenLabs API key</label>
74
+ <div class="row"><input id="elKey" type="password" autocomplete="off" spellcheck="false"><button type="button" class="btn ghost" id="checkEleven">Check key</button></div>
75
+ <p class="status" id="elevenStatus" role="status" aria-live="polite"></p>
76
+ </div>
77
+ <label for="elVoice">Voice ID <span class="opt">optional, defaults to Matilda</span></label>
78
+ <input id="elVoice" type="text" autocomplete="off" spellcheck="false" placeholder="XrExE9yKIg1WjnnlVkGX">
79
+ </div>
80
+ <div class="nav"><button type="button" class="btn ghost" data-back>Back</button><button type="button" class="btn primary" data-next>Continue</button></div>
81
+ </section>
82
+
83
+ <!-- 3 · Speech -->
84
+ <section class="step" data-step="speech" aria-labelledby="h-speech" hidden>
85
+ <p class="eyebrow">Step 3 of 5</p>
86
+ <h1 id="h-speech">Offline speech recognition</h1>
87
+ <p class="lead">Turns your customers' speech into text on this computer, free. Skip it to use Chrome's built-in recognition instead.</p>
88
+ <div class="cards" id="packCards" aria-label="Speech models"></div>
89
+ <div id="packJobs"></div>
90
+ <div class="nav"><button type="button" class="btn ghost" data-back>Back</button><button type="button" class="btn primary" data-next>Continue</button></div>
91
+ </section>
92
+
93
+ <!-- 4 · Access -->
94
+ <section class="step" data-step="access" aria-labelledby="h-access" hidden>
95
+ <p class="eyebrow">Step 4 of 5</p>
96
+ <h1 id="h-access">Who will use this Ava?</h1>
97
+ <div class="cards" role="radiogroup" aria-label="Who will use Ava">
98
+ <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>
99
+ <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>
100
+ </div>
101
+ <div class="field" id="passwordField" hidden>
102
+ <div id="keepPassRow" class="keep" hidden><label><input type="checkbox" id="keepPass" checked> Keep my current password</label></div>
103
+ <div id="newPassRow">
104
+ <label for="password">Access password</label>
105
+ <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>
106
+ <p class="hint">At least 10 characters. You'll type it when you open Ava.</p>
107
+ </div>
108
+ <label class="check"><input type="checkbox" id="proxy"> It will run behind a proxy or host (Fly.io, Cloudflare, nginx, Render…)</label>
109
+ </div>
110
+ <div class="field">
111
+ <label for="port">Port</label>
112
+ <input id="port" type="number" min="1" max="65535" inputmode="numeric" class="short">
113
+ </div>
114
+ <div class="nav"><button type="button" class="btn ghost" data-back>Back</button><button type="button" class="btn primary" data-next>Continue</button></div>
115
+ </section>
116
+
117
+ <!-- 5 · Review -->
118
+ <section class="step" data-step="review" aria-labelledby="h-review" hidden>
119
+ <p class="eyebrow">Step 5 of 5</p>
120
+ <h1 id="h-review">Ready to launch</h1>
121
+ <dl class="summary" id="summary"></dl>
122
+ <p class="status" id="launchStatus" role="status" aria-live="polite"></p>
123
+ <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>
124
+ </section>
125
+
126
+ <!-- launching -->
127
+ <section class="step launching" data-step="launching" aria-live="polite" hidden>
128
+ <div class="orb" aria-hidden="true"></div>
129
+ <h1>Starting Ava…</h1>
130
+ <p class="lead" id="launchMsg">This takes a few seconds. The app opens here when it's ready.</p>
131
+ </section>
132
+ </main>
133
+ </div>
134
+ </body>
135
+ </html>
@@ -0,0 +1,289 @@
1
+ "use strict";
2
+ // Browser setup wizard for `npx ava-engine setup --web`. Talks only to the local
3
+ // setup server; every call carries the one-time token from the URL fragment.
4
+ (() => {
5
+ const $ = (id) => document.getElementById(id);
6
+ const TOKEN = new URLSearchParams(location.hash.slice(1)).get("token") || "";
7
+ const ORDER = ["ai", "voice", "speech", "access", "review"];
8
+ const S = { step: "ai", state: null, keyOk: false, elevenOk: false, selectedPacks: new Set(), pollTimer: null };
9
+
10
+ async function api(path, body) {
11
+ const r = await fetch(`/api/setup/${path}`, {
12
+ method: body ? "POST" : "GET",
13
+ headers: { "X-Setup-Token": TOKEN, ...(body ? { "Content-Type": "application/json" } : {}) },
14
+ body: body ? JSON.stringify(body) : undefined,
15
+ });
16
+ const j = await r.json().catch(() => ({}));
17
+ if (!r.ok) throw Object.assign(new Error(j.error || `error ${r.status}`), { status: r.status });
18
+ return j;
19
+ }
20
+ const radio = (name) => (document.querySelector(`input[name="${name}"]:checked`) || {}).value;
21
+ const fatal = (msg) => { const b = $("fatal"); b.textContent = msg; b.hidden = false; };
22
+ const setStatus = (el, text, kind) => { el.textContent = text; el.className = "status" + (kind ? " " + kind : ""); };
23
+
24
+ // ── step navigation ──
25
+ function show(step) {
26
+ S.step = step;
27
+ document.querySelectorAll(".step").forEach((s) => { s.hidden = s.dataset.step !== step; });
28
+ const idx = ORDER.indexOf(step);
29
+ document.querySelectorAll("#steps li").forEach((li, i) => {
30
+ li.classList.toggle("current", i === idx);
31
+ li.classList.toggle("done", idx > i || step === "launching");
32
+ if (i === idx) li.setAttribute("aria-current", "step"); else li.removeAttribute("aria-current");
33
+ });
34
+ if (step === "voice") syncVoice();
35
+ if (step === "review") renderSummary();
36
+ const h = document.querySelector(`.step[data-step="${step}"] h1`);
37
+ if (h) { h.tabIndex = -1; h.focus({ preventScroll: true }); }
38
+ window.scrollTo({ top: 0 });
39
+ }
40
+ function canLeave(step) {
41
+ if (step === "ai" && radio("mode") === "full" && !(S.state.current.openrouterKey && $("keepKey").checked) && !S.keyOk) {
42
+ setStatus($("keyStatus"), "Connect OpenRouter or check a key first, or choose “Just look around first”.", "err"); return false;
43
+ }
44
+ if (step === "voice" && radio("voice") === "elevenlabs" && !(S.state.current.elevenKey && $("keepEleven").checked) && !S.elevenOk) {
45
+ setStatus($("elevenStatus"), "Check your ElevenLabs key first, or pick another voice.", "err"); return false;
46
+ }
47
+ if (step === "access" && radio("audience") === "others" && !(S.state.current.hasPassword && $("keepPass").checked) && $("password").value.length < 10) {
48
+ $("password").focus(); return false;
49
+ }
50
+ if (step === "access") { const p = Number($("port").value); if (!(Number.isInteger(p) && p > 0 && p < 65536)) { $("port").focus(); return false; } }
51
+ return true;
52
+ }
53
+ document.addEventListener("click", (e) => {
54
+ if (e.target.matches("[data-next]")) { if (canLeave(S.step)) show(ORDER[ORDER.indexOf(S.step) + 1]); }
55
+ if (e.target.matches("[data-back]")) show(ORDER[ORDER.indexOf(S.step) - 1]);
56
+ });
57
+
58
+ // ── 1 · AI ──
59
+ // Connect OpenRouter: sign-in opens in a new tab; the setup server receives the
60
+ // key and keeps it. This page only learns that it worked, plus a masked key.
61
+ async function connectOpenRouter() {
62
+ const tab = window.open("about:blank", "_blank"); // open now, while the click still counts, so it isn't blocked
63
+ setStatus($("connectStatus"), "Opening OpenRouter…");
64
+ try {
65
+ const { url } = await api("openrouter/start", {});
66
+ if (tab) { tab.opener = null; tab.location = url; } else location.assign(url);
67
+ setStatus($("connectStatus"), "Waiting for you to approve in the OpenRouter tab…");
68
+ clearInterval(S.connectTimer);
69
+ S.connectTimer = setInterval(async () => {
70
+ let r; try { r = await api("openrouter/status"); } catch { return; }
71
+ if (r.status === "connected") {
72
+ clearInterval(S.connectTimer);
73
+ S.connected = true; S.keyOk = true; $("orKey").value = "";
74
+ setStatus($("connectStatus"), `✓ Connected (${r.key}). Set a credit limit on it at openrouter.ai/keys.`, "ok");
75
+ setStatus($("keyStatus"), "");
76
+ } else if (r.status === "error") {
77
+ clearInterval(S.connectTimer);
78
+ setStatus($("connectStatus"), `✗ ${r.error}`, "err");
79
+ }
80
+ }, 1500);
81
+ } catch (e) { tab?.close(); setStatus($("connectStatus"), `✗ ${e.message}`, "err"); }
82
+ }
83
+ function syncMode() {
84
+ const full = radio("mode") === "full";
85
+ $("keyField").hidden = !full;
86
+ const hasKey = !!S.state.current.openrouterKey;
87
+ $("keepKeyRow").hidden = !hasKey;
88
+ $("newKeyRow").hidden = hasKey && $("keepKey").checked;
89
+ }
90
+ async function checkKey(service, input, statusEl, okFlag) {
91
+ const key = input.value.trim();
92
+ setStatus(statusEl, "Checking…");
93
+ try {
94
+ const r = await api("check", { service, key });
95
+ S[okFlag] = !!r.ok || !!r.offline;
96
+ if (r.ok) setStatus(statusEl, `✓ Key works${r.label ? ` (${r.label})` : ""}${r.limit != null ? `, credit limit $${r.limit}` : ""}`, "ok");
97
+ else setStatus(statusEl, r.offline ? `${r.error}. It will be saved anyway.` : `✗ ${r.error}`, r.offline ? "" : "err");
98
+ } catch (e) { S[okFlag] = false; setStatus(statusEl, `✗ ${e.message}`, "err"); }
99
+ }
100
+
101
+ // ── 2 · voice ──
102
+ function renderVoices() {
103
+ const v = S.state.voices, cur = S.state.current.voice;
104
+ $("voiceCards").innerHTML = "";
105
+ for (const [id, info] of Object.entries(v)) {
106
+ const label = document.createElement("label"); label.className = "card";
107
+ label.innerHTML = `<input type="radio" name="voice"><span class="card-body"><b></b><span></span></span>`;
108
+ const input = label.querySelector("input"); input.value = id; input.checked = id === cur;
109
+ label.querySelector("b").textContent = info.label;
110
+ label.querySelector(".card-body span").textContent = id === "kokoro" && S.state.kokoro.installed ? "Free, runs on this computer. Installed." : info.hint[0].toUpperCase() + info.hint.slice(1) + ".";
111
+ $("voiceCards").append(label);
112
+ }
113
+ }
114
+ function syncVoice() {
115
+ const voice = radio("voice");
116
+ $("elevenField").hidden = voice !== "elevenlabs";
117
+ const hasEl = !!S.state.current.elevenKey;
118
+ $("keepElevenRow").hidden = !hasEl;
119
+ $("newElevenRow").hidden = hasEl && $("keepEleven").checked;
120
+ // Only download once you're actually choosing a voice, never just because Kokoro is the default.
121
+ if (voice === "kokoro" && !S.state.kokoro.installed && S.step === "voice") startDownload("kokoro");
122
+ }
123
+
124
+ // ── 3 · speech models ──
125
+ function renderPacks() {
126
+ const box = $("packCards"); box.innerHTML = "";
127
+ for (const [id, p] of Object.entries(S.state.packs)) {
128
+ const installed = S.state.installedPacks.includes(id);
129
+ const label = document.createElement("label"); label.className = "card";
130
+ label.innerHTML = `<input type="checkbox"><span class="card-body"><b></b><span></span></span>`;
131
+ const input = label.querySelector("input"); input.value = id;
132
+ input.checked = installed || S.selectedPacks.has(id); input.disabled = installed;
133
+ label.querySelector("b").textContent = `${p.label} · ${p.size}`;
134
+ label.querySelector(".card-body span").textContent = p.hint;
135
+ if (installed) { const badge = document.createElement("span"); badge.className = "badge"; badge.textContent = "installed"; label.append(badge); }
136
+ input.addEventListener("change", () => { if (input.checked) { S.selectedPacks.add(id); startDownload(id); } else S.selectedPacks.delete(id); });
137
+ box.append(label);
138
+ }
139
+ }
140
+
141
+ // ── downloads ──
142
+ async function startDownload(id) {
143
+ try { await api("download", { id }); pollJobs(); } catch (e) { fatal(e.message); }
144
+ }
145
+ function renderJob(container, id, job) {
146
+ let el = document.getElementById(`job-${id}`);
147
+ if (!el) {
148
+ el = document.createElement("div"); el.className = "job"; el.id = `job-${id}`;
149
+ el.innerHTML = `<div class="job-head"><span></span><span></span></div><div class="bar" role="progressbar" aria-valuemin="0" aria-valuemax="100"><i></i></div>`;
150
+ container.append(el);
151
+ }
152
+ el.className = "job" + (job.state === "done" ? " done" : job.state === "error" ? " error" : "");
153
+ el.querySelector(".job-head span").textContent = job.state === "error" ? `${job.label}: failed (${job.error})` : job.state === "done" ? `${job.label}: ready` : `Downloading ${job.label}${job.phase === "unpacking" ? " (unpacking)" : ""}`;
154
+ el.querySelector(".job-head span:last-child").textContent = `${job.pct || 0}%`;
155
+ el.querySelector(".bar").setAttribute("aria-valuenow", String(job.pct || 0));
156
+ el.querySelector(".bar i").style.width = `${job.pct || 0}%`;
157
+ }
158
+ async function pollJobs() {
159
+ clearTimeout(S.pollTimer);
160
+ try {
161
+ const { jobs, installedPacks, kokoroInstalled } = await api("jobs");
162
+ S.state.installedPacks = installedPacks; S.state.kokoro.installed = kokoroInstalled;
163
+ for (const [id, job] of Object.entries(jobs)) {
164
+ if (id === "kokoro") {
165
+ $("kokoroJob").hidden = false;
166
+ $("kokoroJob").className = "job" + (job.state === "done" ? " done" : job.state === "error" ? " error" : "");
167
+ $("kokoroLabel").textContent = job.state === "done" ? "Kokoro voice ready" : job.state === "error" ? `Kokoro voice failed: ${job.error}` : `Downloading the Kokoro voice (${S.state.kokoro.size})`;
168
+ $("kokoroPct").textContent = `${job.pct || 0}%`;
169
+ $("kokoroBar").style.width = `${job.pct || 0}%`;
170
+ } else renderJob($("packJobs"), id, job);
171
+ }
172
+ if (Object.values(jobs).some((j) => j.state === "running")) S.pollTimer = setTimeout(pollJobs, 700);
173
+ else if (S.step === "review") renderSummary();
174
+ } catch { S.pollTimer = setTimeout(pollJobs, 2000); }
175
+ }
176
+
177
+ // ── 4 · access ──
178
+ function syncAudience() {
179
+ const others = radio("audience") === "others";
180
+ $("passwordField").hidden = !others;
181
+ $("keepPassRow").hidden = !S.state.current.hasPassword;
182
+ $("newPassRow").hidden = S.state.current.hasPassword && $("keepPass").checked;
183
+ }
184
+
185
+ // ── 5 · review & launch ──
186
+ function answers() {
187
+ const mode = radio("mode"), voice = radio("voice"), audience = radio("audience");
188
+ return {
189
+ mode,
190
+ openrouterKey: mode === "mock" ? null : S.state.current.openrouterKey && $("keepKey").checked ? "keep" : S.connected && !$("orKey").value.trim() ? "connected" : $("orKey").value.trim(),
191
+ voice,
192
+ elevenKey: voice === "elevenlabs" ? (S.state.current.elevenKey && $("keepEleven").checked ? "keep" : $("elKey").value.trim()) : undefined,
193
+ elevenVoice: voice === "elevenlabs" ? $("elVoice").value.trim() || null : undefined,
194
+ audience,
195
+ password: audience === "others" ? (S.state.current.hasPassword && $("keepPass").checked ? "keep" : $("password").value) : undefined,
196
+ proxy: $("proxy").checked,
197
+ port: Number($("port").value),
198
+ };
199
+ }
200
+ function renderSummary() {
201
+ const a = answers(), packs = S.state.installedPacks.map((id) => S.state.packs[id].label);
202
+ // Wait only for downloads that match the current choices (a voice you switched away from doesn't count).
203
+ const busy = (el) => el && !el.hidden && !el.classList.contains("done") && !el.classList.contains("error");
204
+ const running = (a.voice === "kokoro" && busy($("kokoroJob")) ? 1 : 0)
205
+ + [...S.selectedPacks].filter((id) => busy(document.getElementById(`job-${id}`))).length;
206
+ const rows = [
207
+ ["AI replies", a.mode === "mock" ? "MOCK mode (canned replies)" : "OpenRouter"],
208
+ ["Voice", S.state.voices[a.voice].label],
209
+ ["Speech recognition", packs.length ? packs.join(", ") : "Chrome's built-in recognition"],
210
+ ["Access", a.audience === "me" ? "This computer only" : "Password protected"],
211
+ ["Address", `http://localhost:${a.port}/app`],
212
+ ];
213
+ if (a.audience === "others" && a.password !== "keep") rows.splice(4, 0, ["Password", a.password]);
214
+ const dl = $("summary"); dl.innerHTML = "";
215
+ for (const [k, v] of rows) { const dt = document.createElement("dt"), dd = document.createElement("dd"); dt.textContent = k; dd.textContent = v; dl.append(dt, dd); }
216
+ $("launch").disabled = running > 0;
217
+ setStatus($("launchStatus"), running ? "Waiting for downloads to finish…" : "");
218
+ }
219
+ async function launch() {
220
+ $("launch").disabled = true;
221
+ setStatus($("launchStatus"), "Saving…");
222
+ try {
223
+ const saved = await api("save", { answers: answers() });
224
+ await api("finish", {});
225
+ show("launching");
226
+ const url = saved.url;
227
+ // Wait until *Ava* answers. On the same port the setup server may still be
228
+ // closing, so read the reply and make sure it's Ava's health report. On a
229
+ // different port anything that answers is Ava (no-cors can't read it).
230
+ const samePort = String(saved.port) === (location.port || "80");
231
+ for (let i = 0; i < 120; i++) {
232
+ try {
233
+ if (samePort) {
234
+ const r = await fetch("/api/health", { cache: "no-store" });
235
+ if (r.ok && "models" in (await r.json())) { location.replace(url); return; }
236
+ } else {
237
+ await fetch(`http://localhost:${saved.port}/api/health`, { mode: "no-cors", cache: "no-store" });
238
+ location.replace(url); return;
239
+ }
240
+ } catch {}
241
+ await new Promise((r) => setTimeout(r, 500));
242
+ }
243
+ $("launchMsg").textContent = `Ava is taking a while. Open ${url} when it's ready, or check the terminal.`;
244
+ } catch (e) {
245
+ setStatus($("launchStatus"), `✗ ${e.message}`, "err");
246
+ $("launch").disabled = false;
247
+ }
248
+ }
249
+
250
+ // ── boot ──
251
+ async function boot() {
252
+ if (!TOKEN) return fatal("This page needs the link printed by `npx ava-engine setup --web`.");
253
+ try { S.state = await api("state"); } catch (e) { return fatal(e.message); }
254
+ history.replaceState(null, "", "/setup"); // drop the token from the address bar (it stays in memory)
255
+ const c = S.state.current;
256
+ $("configFile").textContent = S.state.configFile;
257
+ if (c.openrouterKey) { $("currentKey").textContent = c.openrouterKey; }
258
+ if (c.elevenKey) $("currentEleven").textContent = c.elevenKey;
259
+ $("elVoice").value = c.elevenVoice;
260
+ document.querySelector(`input[name="audience"][value="${c.audience}"]`).checked = true;
261
+ $("password").value = S.state.suggestedPassword;
262
+ $("proxy").checked = c.proxy;
263
+ $("port").value = c.port;
264
+ renderVoices(); renderPacks();
265
+ syncMode(); syncVoice(); syncAudience();
266
+
267
+ document.querySelectorAll('input[name="mode"]').forEach((i) => i.addEventListener("change", syncMode));
268
+ $("keepKey").addEventListener("change", syncMode);
269
+ $("voiceCards").addEventListener("change", syncVoice);
270
+ $("keepEleven").addEventListener("change", syncVoice);
271
+ document.querySelectorAll('input[name="audience"]').forEach((i) => i.addEventListener("change", syncAudience));
272
+ $("keepPass").addEventListener("change", syncAudience);
273
+ $("orKey").addEventListener("input", () => { S.keyOk = !!S.connected && !$("orKey").value.trim(); setStatus($("keyStatus"), ""); });
274
+ $("connectOr").addEventListener("click", connectOpenRouter);
275
+ $("elKey").addEventListener("input", () => { S.elevenOk = false; setStatus($("elevenStatus"), ""); });
276
+ $("checkKey").addEventListener("click", () => checkKey("openrouter", $("orKey"), $("keyStatus"), "keyOk"));
277
+ $("checkEleven").addEventListener("click", () => checkKey("elevenlabs", $("elKey"), $("elevenStatus"), "elevenOk"));
278
+ $("orKey").addEventListener("keydown", (e) => { if (e.key === "Enter") $("checkKey").click(); });
279
+ $("regen").addEventListener("click", async () => { $("password").value = (await api("password")).password; });
280
+ $("copyPass").addEventListener("click", async () => { try { await navigator.clipboard.writeText($("password").value); $("copyPass").textContent = "Copied"; setTimeout(() => { $("copyPass").textContent = "Copy"; }, 1400); } catch {} });
281
+ $("launch").addEventListener("click", launch);
282
+ show("ai");
283
+ pollJobs();
284
+ }
285
+ // A fresh setup link opened in this same tab only changes the #token, which
286
+ // doesn't reload the page; reload so the new token is used.
287
+ window.addEventListener("hashchange", () => { if (/token=/.test(location.hash)) location.reload(); });
288
+ boot();
289
+ })();
package/public/style.css CHANGED
@@ -96,6 +96,10 @@ b { font-weight: 600; }
96
96
  .pill { display: flex; align-items: center; gap: 7px; padding: 5px 11px 5px 9px; border-radius: 999px; font-family: var(--mono); font-size: 10px; font-weight: 500; letter-spacing: 0.1em; }
97
97
  .pill .dot { width: 6px; height: 6px; border-radius: 50%; }
98
98
  .pill.standby { background: var(--control); border: 1px solid var(--edge); color: var(--ink2); }
99
+ .pill.demo { background: #FFF6E5; border: 1px solid #F2D49A; color: #7A4B00; }
100
+ .pill.demo[hidden], .ob-demo[hidden] { display: none; }
101
+ .ob-demo { margin-top: 12px; padding: 10px 14px; border-radius: 10px; background: #FFF6E5; border: 1px solid #F2D49A; color: #5C3A00; font-size: 13.5px; }
102
+ .ob-demo code { font-family: var(--mono); font-size: 12.5px; }
99
103
  .pill.standby .dot { border: 1px solid var(--meta); }
100
104
  .pill.live { background: var(--brandtint); border: 1px solid var(--brandline); color: var(--amber); }
101
105
  .pill.live .dot { background: var(--amber); }
package/server.mjs CHANGED
@@ -12,8 +12,10 @@ import { oursTurnStream, greetStream, observeUpdate, screenOff, proactiveNudge,
12
12
  import { synth, TTS_ENABLED, providers, defaultProvider, warmKokoro, kokoroStream, KOKORO_STREAMS, elevenStream, ELEVEN_STREAMS, TtsBusyError } from "./lib/tts.mjs";
13
13
  import { transcribe, STT_ENABLED } from "./lib/stt.mjs";
14
14
  import { createAsrSession, asrAvailable, warmAsr, availableEngines, defaultEngine, ENGINE_LABELS } from "./lib/asr.mjs";
15
- import { platformsView, platformById } from "./lib/platforms.mjs";
15
+ import { platformsView, platformById, CUSTOM_PLAYBOOKS } from "./lib/platforms.mjs";
16
16
  import { AUTH_ENABLED, isAuthorized, challengeHeaders, authCookieHeader, clientIp, sameOrigin, limiter } from "./lib/guard.mjs";
17
+ import { emitEvent, WEBHOOKS_ENABLED } from "./lib/webhooks.mjs";
18
+ import { DAILY_BUDGET_USD, dailyCapReached, spentToday } from "./lib/spend.mjs";
17
19
  import { WebSocketServer } from "ws";
18
20
 
19
21
  // A stray rejection is logged, not fatal. An uncaught exception leaves the process
@@ -93,6 +95,7 @@ function send(res, code, body, headers = {}) {
93
95
  // Upstream errors (OpenRouter, ElevenLabs, Deepgram…) can carry account or quota
94
96
  // detail. Log it here; the browser only gets a short, generic reason.
95
97
  function upstreamFail(res, e, what) {
98
+ if (e?.code === "daily_cap") return send(res, 503, { error: CAP_MSG }, { "Retry-After": untilMidnightUtc() });
96
99
  console.error(` [${what}]`, e?.message || e);
97
100
  return send(res, e?.status === 429 ? 429 : 502, { error: `${what} failed` });
98
101
  }
@@ -125,7 +128,14 @@ function readBody(req, limit = 3 * 1024 * 1024) {
125
128
 
126
129
  function safeHost(req) { const h = String(req.headers.host || ""); return /^[a-z0-9.-]+(:\d+)?$/i.test(h) ? h : ""; }
127
130
  function validFrame(f) { return typeof f === "string" && f.length <= MAX_FRAME_BYTES && FRAME_RE.test(f) ? f : null; }
128
- function overBudget(sess) { return sess.score.ours.cost >= SESSION_BUDGET_USD; }
131
+ const CAP_MSG = "Ava has reached today's usage limit — try again tomorrow";
132
+ /** Why a session can't spend more (its own budget, or the daily cap across all sessions), or null. */
133
+ function spendLimit(sess) {
134
+ if (!MOCK && dailyCapReached()) return CAP_MSG;
135
+ if (sess.score.ours.cost >= SESSION_BUDGET_USD) return "this session reached its spend limit — start a new call";
136
+ return null;
137
+ }
138
+ const untilMidnightUtc = () => String(Math.ceil((new Date().setUTCHours(24, 0, 0, 0) - Date.now()) / 1000));
129
139
 
130
140
  async function serveStatic(req, res, pathname) {
131
141
  let p;
@@ -201,6 +211,7 @@ async function handle(req, res) {
201
211
  if (req.method === "GET" && url.pathname === "/api/health") {
202
212
  return send(res, 200, {
203
213
  mock: MOCK,
214
+ dailyCapReached: !MOCK && dailyCapReached(),
204
215
  models: { talker: MODELS.talker, perception: MODELS.perception, planner: MODELS.planner, intent: MODELS.intent },
205
216
  labels: ROLE_LABEL,
206
217
  tts: { enabled: TTS_ENABLED, providers: providers(), default: defaultProvider() },
@@ -285,11 +296,13 @@ async function handle(req, res) {
285
296
  if (req.method === "POST" && url.pathname === "/api/session") {
286
297
  if (!rate.session(clientIp(req))) return send(res, 429, { error: "too many new sessions — try again in a minute" }, { "Retry-After": "60" });
287
298
  if (!hasCapacity()) return send(res, 503, { error: "Ava is at capacity right now — try again in a few minutes" }, { "Retry-After": "60" });
299
+ if (!MOCK && dailyCapReached()) return send(res, 503, { error: CAP_MSG }, { "Retry-After": untilMidnightUtc() });
288
300
  const body = await readBody(req);
289
301
  const mode = body.mode === "onboarding" ? "onboarding" : "flexible";
290
302
  const platform = typeof body.platformId === "string" ? platformById(body.platformId) : null; // knowledge in any mode
291
303
  const s = newSession({ mode, platform });
292
304
  if (!s) return send(res, 503, { error: "Ava is at capacity right now — try again in a few minutes" }, { "Retry-After": "60" });
305
+ emitEvent("session.started", { sessionId: s.id, mode, platform: platform ? { id: platform.id, name: platform.name } : null });
293
306
  // pre-warm the talker route so the greeting isn't a cold start
294
307
  if (!MOCK) chat({ model: MODELS.talker, role: "talker", provider: PROVIDER.talker, messages: [{ role: "user", content: "hi" }], maxTokens: 1 }).catch(() => {});
295
308
  return send(res, 200, { sessionId: s.id, mock: MOCK, models: MODELS, mode, platform: platform ? { id: platform.id, name: platform.name, url: platform.url } : null });
@@ -300,7 +313,8 @@ async function handle(req, res) {
300
313
  const { sessionId, frameHash = null, greet = false, voice = false, changed = false, screenActive = false } = body;
301
314
  const sess = getSession(sessionId);
302
315
  if (!sess) return send(res, 404, { error: "unknown session" });
303
- if (overBudget(sess)) return send(res, 402, { error: "this session reached its spend limit — start a new call" });
316
+ const limit = spendLimit(sess);
317
+ if (limit) return send(res, 402, { error: limit });
304
318
  const userText = String(body.userText ?? "").slice(0, MAX_USER_TEXT);
305
319
  const frame = validFrame(body.frame);
306
320
 
@@ -310,8 +324,8 @@ async function handle(req, res) {
310
324
  if (greet) await greetStream(sess, emit, !!voice);
311
325
  else await oursTurnStream(sess, userText, frame, !!screenActive, frameHash, emit, !!voice, !!changed);
312
326
  } catch (e) {
313
- console.error(" [turn]", e?.message || e);
314
- emit("error", { error: "Ava couldn't answer that one — try again" });
327
+ if (e?.code === "daily_cap") emit("error", { error: CAP_MSG });
328
+ else { console.error(" [turn]", e?.message || e); emit("error", { error: "Ava couldn't answer that one — try again" }); }
315
329
  }
316
330
  res.end();
317
331
  return;
@@ -322,7 +336,7 @@ async function handle(req, res) {
322
336
  const sess = getSession(sessionId);
323
337
  if (!sess) return send(res, 404, { error: "unknown session" });
324
338
  if (stopped) return send(res, 200, screenOff(sess)); // user stopped sharing → mark state stale now
325
- if (overBudget(sess)) return send(res, 402, { error: "spend limit reached" });
339
+ if (spendLimit(sess)) return send(res, 402, { error: spendLimit(sess) });
326
340
  const frame = validFrame(rawFrame);
327
341
  if (!frame) return send(res, 400, { error: "no frame" });
328
342
  try { return send(res, 200, await observeUpdate(sess, frame, frameHash, !!changed)); }
@@ -382,7 +396,7 @@ async function handle(req, res) {
382
396
  const { sessionId } = await readBody(req);
383
397
  const sess = getSession(sessionId);
384
398
  if (!sess) return send(res, 404, { error: "unknown session" });
385
- if (overBudget(sess)) return send(res, 402, { error: "spend limit reached" });
399
+ if (spendLimit(sess)) return send(res, 402, { error: spendLimit(sess) });
386
400
  try { return send(res, 200, await proactiveNudge(sess)); }
387
401
  catch (e) { return upstreamFail(res, e, "nudge"); }
388
402
  }
@@ -493,6 +507,9 @@ server.listen(PORT, HOST, () => {
493
507
  console.log(` → http://localhost:${PORT}/app`);
494
508
  console.log(` mode: ${MOCK ? "MOCK (no OPENROUTER_API_KEY set — synthetic replies)" : "LIVE (OpenRouter)"}`);
495
509
  if (!MOCK) console.log(` models: talker=${MODELS.talker} perception=${MODELS.perception} planner=${MODELS.planner} intent=${MODELS.intent}`);
510
+ if (!MOCK) console.log(DAILY_BUDGET_USD ? ` daily cap: $${DAILY_BUDGET_USD} (spent today: $${spentToday().toFixed(2)})` : " daily cap: none (set DAILY_BUDGET_USD to limit spend across all calls)");
511
+ if (CUSTOM_PLAYBOOKS.count) console.log(` playbooks: ${CUSTOM_PLAYBOOKS.count} from ${CUSTOM_PLAYBOOKS.dir}`);
512
+ if (WEBHOOKS_ENABLED) console.log(" webhooks: on");
496
513
  if (LOCAL_ONLY) console.log(" access: this computer only");
497
514
  else if (!AUTH_ENABLED) console.log(" access: OPEN — anyone who can reach this server can spend your API credit. Set ACCESS_PASSWORD before exposing it.");
498
515
  else console.log(" access: password protected");