ava-engine 0.3.0 → 0.4.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/tts.mjs CHANGED
@@ -4,20 +4,28 @@
4
4
  // is the on-device Web Speech fallback.
5
5
 
6
6
  import { loadKokoro, kokoroInstalled } from "./kokoro-model.mjs";
7
+ import { onSettingsChange } from "./settings.mjs";
7
8
 
8
- const HAS_ELEVEN = !!process.env.ELEVENLABS_API_KEY;
9
- const HAS_HUME = !!process.env.HUME_API_KEY;
10
- const HAS_KOKORO = process.env.KOKORO !== "off"; // local, free — on by default
11
-
12
- const ELEVEN_VOICE = process.env.ELEVEN_VOICE_ID || "XrExE9yKIg1WjnnlVkGX";
9
+ const HAS_KOKORO = process.env.KOKORO !== "off"; // local, free — on by default (loading it needs a restart)
13
10
  const ELEVEN_MODEL = process.env.ELEVEN_MODEL || "eleven_turbo_v2_5";
14
- const ELEVEN_LABEL = process.env.ELEVEN_LABEL || "Matilda";
15
- const HUME_VOICE = process.env.HUME_VOICE_ID || "a7ecc00a-6fc0-4546-8126-e12cfd8de3bf";
16
- const HUME_LABEL = process.env.HUME_LABEL || "Aria";
17
11
  const HUME_DESC = process.env.HUME_DESCRIPTION || "Warm, empathetic, natural and reassuring";
18
- const KOKORO_VOICE = process.env.KOKORO_VOICE || "af_heart";
19
12
 
20
- export const TTS_ENABLED = HAS_KOKORO || HAS_ELEVEN || HAS_HUME;
13
+ // Keys and voices can change from the dashboard; these reload when they do.
14
+ let HAS_ELEVEN, HAS_HUME, ELEVEN_VOICE, ELEVEN_LABEL, HUME_VOICE, HUME_LABEL, KOKORO_VOICE;
15
+ export let TTS_ENABLED, ELEVEN_STREAMS;
16
+ function load() {
17
+ HAS_ELEVEN = !!process.env.ELEVENLABS_API_KEY;
18
+ HAS_HUME = !!process.env.HUME_API_KEY;
19
+ ELEVEN_VOICE = process.env.ELEVEN_VOICE_ID || "XrExE9yKIg1WjnnlVkGX";
20
+ ELEVEN_LABEL = process.env.ELEVEN_LABEL || "Matilda";
21
+ HUME_VOICE = process.env.HUME_VOICE_ID || "a7ecc00a-6fc0-4546-8126-e12cfd8de3bf";
22
+ HUME_LABEL = process.env.HUME_LABEL || "Aria";
23
+ KOKORO_VOICE = process.env.KOKORO_VOICE || "af_heart";
24
+ TTS_ENABLED = HAS_KOKORO || HAS_ELEVEN || HAS_HUME;
25
+ ELEVEN_STREAMS = HAS_ELEVEN;
26
+ }
27
+ load();
28
+ onSettingsChange(load);
21
29
  const FETCH_TIMEOUT_MS = 20_000;
22
30
  const MAX_KOKORO_QUEUE = Number(process.env.KOKORO_MAX_QUEUE) || 6;
23
31
 
@@ -132,7 +140,6 @@ async function eleven(text) {
132
140
  as Kokoro) so first audio lands in ~0.3-0.5s instead of waiting for the whole
133
141
  clip. previous_text is passed so prosody stays continuous across chunks. Each
134
142
  chunk is a complete MP3 the client decodes independently. onChunk(mp3, text). */
135
- export const ELEVEN_STREAMS = HAS_ELEVEN;
136
143
  export async function elevenStream(text, onChunk) {
137
144
  const clean = String(text || "").slice(0, 800).trim();
138
145
  if (!clean) throw new Error("empty text");
package/lib/webhooks.mjs CHANGED
@@ -3,6 +3,8 @@
3
3
  // WEBHOOK_URL one or more URLs (comma-separated) that receive every event as JSON
4
4
  // WEBHOOK_SECRET signs each delivery: X-Ava-Signature: sha256=HMAC(secret, `${timestamp}.${body}`)
5
5
  // SLACK_WEBHOOK_URL a Slack incoming webhook; posts when a customer asks for a person
6
+ // SLACK_BOT_TOKEN + SLACK_CHANNEL instead: one thread per handoff, with "picked up
7
+ // by…" and "call ended…" replies (a bot with chat:write)
6
8
  // PUBLIC_URL where specialists open Ava (for the links in those messages)
7
9
  //
8
10
  // Events: session.started, step.completed, workflow.completed, handoff.requested,
@@ -11,12 +13,23 @@
11
13
 
12
14
  import { createHmac, randomUUID } from "node:crypto";
13
15
 
14
- const URLS = (process.env.WEBHOOK_URL || "").split(",").map((u) => u.trim()).filter(Boolean);
15
- const SECRET = process.env.WEBHOOK_SECRET || "";
16
- const SLACK = process.env.SLACK_WEBHOOK_URL || "";
17
- const TIMEOUT_MS = 5_000, RETRY_MS = 2_000;
16
+ import { onSettingsChange } from "./settings.mjs";
18
17
 
19
- export const WEBHOOKS_ENABLED = URLS.length > 0 || !!SLACK;
18
+ const TIMEOUT_MS = 5_000, RETRY_MS = 2_000;
19
+ let URLS = [], SECRET = "", SLACK = "", BOT = "", CHANNEL = "";
20
+ export let WEBHOOKS_ENABLED = false;
21
+ /** What's configured, for the dashboard. Never the URLs themselves (Slack URLs are secrets). */
22
+ export const WEBHOOK_INFO = { endpoints: 0, hosts: [], signed: false, slack: false };
23
+ function load() {
24
+ URLS = (process.env.WEBHOOK_URL || "").split(",").map((u) => u.trim()).filter(Boolean);
25
+ SECRET = process.env.WEBHOOK_SECRET || "";
26
+ SLACK = process.env.SLACK_WEBHOOK_URL || "";
27
+ BOT = process.env.SLACK_BOT_TOKEN || ""; CHANNEL = process.env.SLACK_CHANNEL || "";
28
+ WEBHOOKS_ENABLED = URLS.length > 0 || !!SLACK || !!(BOT && CHANNEL);
29
+ Object.assign(WEBHOOK_INFO, { endpoints: URLS.length, hosts: URLS.map((u) => { try { return new URL(u).host; } catch { return "?"; } }), signed: !!SECRET, slack: !!SLACK || !!(BOT && CHANNEL), slackThreads: !!(BOT && CHANNEL) });
30
+ }
31
+ load();
32
+ onSettingsChange(load);
20
33
 
21
34
  /** The URL a specialist opens to join a call. */
22
35
  export function specialistUrl(key) {
@@ -45,6 +58,8 @@ export function sign(body, timestamp, secret = SECRET) {
45
58
  }
46
59
 
47
60
  function slackText(type, data) {
61
+ if (type === "ava.test") return ":white_check_mark: Ava is connected to this channel. You'll get a message here when a customer asks for a person.";
62
+ if (type === "handoff.picked_up") return null; // threads only
48
63
  if (type !== "handoff.requested") return null;
49
64
  const where = data.platform ? ` on ${data.platform.name}` : "";
50
65
  const step = data.workflow?.currentStep ? `\n*Stuck on:* ${data.workflow.currentStep} (${data.workflow.done}/${data.workflow.total} steps done)` : "";
@@ -62,7 +77,36 @@ export function emitEvent(type, data) {
62
77
  const headers = { "X-Ava-Event": type, "X-Ava-Delivery": event.id, "X-Ava-Timestamp": ts, ...(SECRET ? { "X-Ava-Signature": sign(body, ts) } : {}) };
63
78
  return post(url, body, headers, "webhook");
64
79
  });
65
- const text = SLACK && slackText(type, data);
66
- if (text) jobs.push(post(SLACK, JSON.stringify({ text }), {}, "slack"));
80
+ if (BOT && CHANNEL) jobs.push(slackBot(type, data));
81
+ else { const text = SLACK && slackText(type, data); if (text) jobs.push(post(SLACK, JSON.stringify({ text }), {}, "slack")); }
67
82
  return Promise.allSettled(jobs);
68
83
  }
84
+
85
+ // ── Slack threads (bot token): the handoff opens a thread; updates reply in it ──
86
+ const threads = new Map(); // sessionId → thread ts (bounded)
87
+ async function slackApi(body) {
88
+ try {
89
+ const r = await fetch("https://slack.com/api/chat.postMessage", { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8", Authorization: `Bearer ${BOT}` }, body: JSON.stringify({ channel: CHANNEL, unfurl_links: false, ...body }), signal: AbortSignal.timeout(TIMEOUT_MS) });
90
+ const j = await r.json();
91
+ if (!j.ok) console.warn(` [slack] ${j.error || r.status}`);
92
+ return j.ok ? j : null;
93
+ } catch { console.warn(" [slack] delivery failed"); return null; }
94
+ }
95
+ const mins = (s) => `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, "0")}`;
96
+ async function slackBot(type, data) {
97
+ if (type === "ava.test") return !!(await slackApi({ text: slackText(type, data) }));
98
+ if (type === "handoff.requested") {
99
+ const j = await slackApi({ text: slackText(type, data) });
100
+ if (j) { threads.set(data.sessionId, j.ts); if (threads.size > 500) threads.delete(threads.keys().next().value); }
101
+ return !!j;
102
+ }
103
+ const ts = threads.get(data.sessionId);
104
+ if (!ts) return true;
105
+ if (type === "handoff.picked_up") return !!(await slackApi({ thread_ts: ts, text: `:handshake: Picked up by *${data.by}*.` }));
106
+ if (type === "session.ended") {
107
+ threads.delete(data.sessionId);
108
+ const steps = data.workflow ? ` · ${data.workflow.done}/${data.workflow.total} steps${data.workflow.completed ? " (finished)" : ""}` : "";
109
+ return !!(await slackApi({ thread_ts: ts, text: `:checkered_flag: Call ended after ${mins(data.durationSec)}${steps}.` }));
110
+ }
111
+ return true;
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ava-engine",
3
- "version": "0.3.0",
3
+ "version": "0.4.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": [
@@ -39,9 +39,9 @@
39
39
  "scripts": {
40
40
  "start": "node --env-file-if-exists=.env server.mjs",
41
41
  "dev": "node --watch --env-file-if-exists=.env server.mjs",
42
- "test": "node --test test/*.test.mjs",
42
+ "test": "node --test --import ./test/env.mjs test/*.test.mjs",
43
43
  "check": "node --check server.mjs && for f in lib/*.mjs public/*.js; do node --check \"$f\" || exit 1; done",
44
- "test:e2e": "node --test --test-concurrency=1 test/e2e/*.e2e.mjs",
44
+ "test:e2e": "node --test --import ./test/env.mjs --test-concurrency=1 test/e2e/*.e2e.mjs",
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",
package/public/app.js CHANGED
@@ -17,6 +17,10 @@ const S = {
17
17
  workflow: null, handoffTimer: null, knowTimer: null,
18
18
  specialistPresent: false, bridgeSince: 0, bridgeTimer: null, rtc: null, rtcMic: null,
19
19
  };
20
+ // Opened from a share link (/c/<id>)? Then this is a customer's call, set up by the link.
21
+ const LINK_ID = (location.pathname.match(/^\/c\/([A-Za-z0-9_-]{22})\/?$/) || [])[1] || null;
22
+ S.link = null; // the link's public view: { title, intro, mode, platform, options }
23
+ if (LINK_ID) document.documentElement.classList.add("customer");
20
24
  const video = $("video"), capcanvas = $("capcanvas"), hashcanvas = $("hashcanvas");
21
25
  let pip = null;
22
26
 
@@ -54,10 +58,51 @@ let pip = null;
54
58
  sel.onchange = () => { S.voice = sel.value; $("voicesel").value = S.voice; $("voicesel2").value = S.voice; };
55
59
  }
56
60
  initOnboard(h);
61
+ if (LINK_ID) await initLink(!!h.mock); else applyUrlParams();
57
62
  } catch {}
58
63
  populateMics();
59
64
  })();
60
65
 
66
+ /* ═══════════ share links + URL presets ═══════════ */
67
+ // /app?mode=onboarding&product=<id> preselects the lobby (the dashboard's "Test" buttons use it).
68
+ function applyUrlParams() {
69
+ const q = new URLSearchParams(location.search);
70
+ const mode = q.get("mode"), product = q.get("product");
71
+ if (mode === "onboarding" || mode === "flexible") selectMode(mode);
72
+ if (product && S.platforms.some((p) => p.id === product)) { if (!mode) selectMode("onboarding"); selectPlatform(product); }
73
+ }
74
+ async function initLink(demo) {
75
+ let v;
76
+ try {
77
+ const r = await fetch(`/api/link/${LINK_ID}`);
78
+ v = await r.json().catch(() => ({}));
79
+ if (!r.ok) return linkUnavailable(v.error || "This link isn't available right now.");
80
+ } catch { return linkUnavailable("Couldn't reach Ava. Check your connection and reload the page."); }
81
+ S.link = v;
82
+ $("transcript").classList.add("noagents"); // customers see the conversation, not the agents' work
83
+ S.agentMode = v.mode;
84
+ S.platform = v.platform ? { ...v.platform, category: "" } : null;
85
+ if (S.platform && S.platform.accent) applyBrand(S.platform.accent);
86
+ document.title = v.platform ? `${v.platform.name} · Live guide` : "Ava · Live guide";
87
+ $("obAsideTitle").textContent = v.title;
88
+ $("obAsideLead").textContent = v.intro;
89
+ $("obTitle").textContent = "Ready when you are";
90
+ $("obLead").textContent = "Check your microphone, then start. You can type instead of talking at any time.";
91
+ $("obAudioLabel").textContent = "Your microphone";
92
+ $("joinvoiceText").textContent = "Start with voice";
93
+ $("jointext").textContent = "Type instead";
94
+ if (demo) $("obDemoText").textContent = "Ava's replies are canned for now.";
95
+ const steps = v.platform && v.options.steps ? v.platform.journey : [];
96
+ $("obJourneyBlock").hidden = !steps.length;
97
+ $("obJourney").innerHTML = steps.map((s) => `<li>${esc(s)}</li>`).join("");
98
+ }
99
+ function linkUnavailable(msg) {
100
+ document.querySelector(".ob-panel").classList.add("ob-unavailable");
101
+ $("obTitle").textContent = "This link isn't available";
102
+ $("obLead").textContent = msg;
103
+ $("joinvoice").disabled = true; $("jointext").disabled = true;
104
+ }
105
+
61
106
  /* ═══════════ onboard: agent mode + platform pickers ═══════════ */
62
107
  function initOnboard(h) {
63
108
  const modes = (h.modes && h.modes.length) ? h.modes : [
@@ -229,7 +274,7 @@ async function joinCall(mode) {
229
274
  fetch("/api/tts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Hi.", provider: S.voice }) }).then((r) => r.arrayBuffer()).catch(() => {});
230
275
  }
231
276
  try {
232
- const res = await fetch("/api/session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: S.agentMode, platformId: S.platform ? S.platform.id : null }) });
277
+ const res = await fetch("/api/session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: S.agentMode, platformId: S.platform ? S.platform.id : null, ...(LINK_ID ? { link: LINK_ID } : {}) }) });
233
278
  const r = await res.json().catch(() => ({}));
234
279
  if (!res.ok || !r.sessionId) throw new Error(r.error || `server error ${res.status}`);
235
280
  S.sessionId = r.sessionId; S.joined = true;
@@ -266,6 +311,12 @@ async function joinCall(mode) {
266
311
  }
267
312
  function setTopbarForMode() {
268
313
  const onb = S.agentMode === "onboarding" && S.platform;
314
+ if (S.link) { // customers see the product, not the engine
315
+ $("apptitle").textContent = S.platform ? S.platform.name : "Ava";
316
+ $("topsub").textContent = "your live guide";
317
+ $("footmode").textContent = "recording off · transcript on";
318
+ return;
319
+ }
269
320
  $("apptitle").textContent = onb ? `${S.platform.name} Onboarding` : "Flexible Agent";
270
321
  $("topsub").textContent = onb ? `onboarding guide · ${S.platform.category || "platform"}` : "screen-aware assistant";
271
322
  $("footmode").textContent = onb ? `onboarding ${S.platform.name} · transcript on` : "recording off · transcript on";
@@ -580,6 +631,7 @@ function frameHash() {
580
631
 
581
632
  /* ═══════════ console drawer ═══════════ */
582
633
  function consoleToggle(force) {
634
+ if (S.link) return; // no console on customers' calls
583
635
  const open = force != null ? force : $("console").hidden;
584
636
  $("console").hidden = !open;
585
637
  $("consolebtn").classList.toggle("on", open);
@@ -863,6 +915,8 @@ function setStuck(stuck) {
863
915
  /* ═══════════ workflow tracker + human handoff + RAG indicator ═══════════ */
864
916
  function renderWorkflow(wf) {
865
917
  S.workflow = wf || null;
918
+ $("workflow").classList.toggle("wf-nosteps", !!(S.link && !S.link.options.steps));
919
+ $("wfHandoff").hidden = !!(S.link && !S.link.options.handoff);
866
920
  if (wf && wf.handoff) S.handoff = wf.handoff;
867
921
  if (!wf) {
868
922
  // A re-plan can clear the tracker mid-handoff; keep the panel (and its
@@ -898,6 +952,10 @@ function renderHandoff(h) {
898
952
  link.hidden = true;
899
953
  } else {
900
954
  spin.style.display = "";
955
+ if (S.link) { // the team is told (dashboard, Slack); the customer never handles the specialist key
956
+ txt.textContent = "We've let the team know. A specialist will join you here shortly.";
957
+ link.hidden = true; return;
958
+ }
901
959
  txt.textContent = "Share this link with a specialist so they can join:";
902
960
  link.hidden = !S.specialistKey; link.textContent = specialistURL();
903
961
  }
@@ -1052,7 +1110,17 @@ async function showReport(sid, durMs, rawTape) {
1052
1110
  $("repPlay").disabled = !tape.length;
1053
1111
  $("report").hidden = false;
1054
1112
 
1113
+ if (S.link) {
1114
+ $("repFootnote").textContent = "Thanks for using your live guide.";
1115
+ $("repNew").textContent = "Start again";
1116
+ }
1055
1117
  if (!sid) { $("repSummary").textContent = "Recap unavailable — no active session."; return; }
1118
+ if (S.link && !S.link.options.recap) { // this link skips the AI recap: just free the session
1119
+ fetch("/api/end", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: sid }), keepalive: true }).catch(() => {});
1120
+ $("repHeadline").textContent = "Thanks, you're all set";
1121
+ $("repSummary").textContent = "You can close this tab, or start again if you need more help.";
1122
+ return;
1123
+ }
1056
1124
  try {
1057
1125
  const s = await fetch("/api/summary", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: sid }) }).then((r) => r.json())
1058
1126
  .finally(() => { // the recap was the last use — free the session slot now
@@ -1615,7 +1683,16 @@ async function playWavStream(res, abort) {
1615
1683
  return true;
1616
1684
  }
1617
1685
 
1618
- async function speak(text, force, ignoreMute) {
1686
+ // Ava keeps one voice. If the chosen server voice (Kokoro by default) can't speak
1687
+ // a line, it retries once, then shows the reply as text: it never swaps to the
1688
+ // browser's robotic voice mid-call. The browser voice is used only when picked.
1689
+ let voiceNoticeShown = false;
1690
+ function voiceUnavailable() {
1691
+ if (voiceNoticeShown) return;
1692
+ voiceNoticeShown = true;
1693
+ addSysLine("voice", "Ava's voice couldn't play that reply, so it's shown as text. Ava will keep trying her voice.");
1694
+ }
1695
+ async function speak(text, force, ignoreMute, retried = false) {
1619
1696
  if (!ignoreMute && !$("speak").checked) return false;
1620
1697
  if (!force && lastSpoken && simText(text, lastSpoken) > 0.7) { lastSpoken = text; return false; }
1621
1698
  lastSpoken = text; S.curSpokenText = text;
@@ -1626,7 +1703,7 @@ async function speak(text, force, ignoreMute) {
1626
1703
  const abort = ttsFetch = new AbortController();
1627
1704
  try {
1628
1705
  const res = await fetch("/api/tts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text, provider: prov }), signal: abort.signal });
1629
- if (!res.ok) throw new Error("tts " + res.status);
1706
+ if (!res.ok) throw Object.assign(new Error("tts " + res.status), { status: res.status });
1630
1707
  if ((res.headers.get("Content-Type") || "").includes("x-wav-stream")) {
1631
1708
  if (ttsFetch === abort) ttsFetch = null; // playWavStream owns the abort from here
1632
1709
  if (await playWavStream(res, abort)) return true;
@@ -1644,6 +1721,10 @@ async function speak(text, force, ignoreMute) {
1644
1721
  // an aborted request means a newer speak/barge-in superseded this one —
1645
1722
  // stay silent instead of re-speaking the text in the browser voice
1646
1723
  if (abort.signal.aborted || (e && e.name === "AbortError")) return false;
1724
+ // busy (another line still synthesising) or a hiccup: one more try with the same voice
1725
+ if (!retried) { await wait(e?.status === 429 ? 900 : 400); if (!abort.signal.aborted && ttsFetch !== null && ttsFetch !== abort) return false; return speak(text, true, ignoreMute, true); }
1726
+ voiceUnavailable();
1727
+ return false;
1647
1728
  }
1648
1729
  }
1649
1730
  if ("speechSynthesis" in window) {