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.
- package/.env.example +11 -0
- package/README.md +14 -10
- package/lib/asr.mjs +5 -1
- package/lib/cli/apply.mjs +58 -0
- package/lib/cli/main.mjs +77 -9
- package/lib/cli/openrouter-oauth.mjs +87 -0
- package/lib/cli/setup-server.mjs +222 -0
- package/lib/cli/setup.mjs +118 -80
- package/lib/kokoro-model.mjs +51 -0
- package/lib/mock.mjs +78 -0
- package/lib/openrouter.mjs +5 -0
- package/lib/ours.mjs +40 -28
- package/lib/platforms.mjs +15 -1
- package/lib/playbooks.mjs +111 -0
- package/lib/spend.mjs +59 -0
- package/lib/state.mjs +14 -5
- package/lib/tts.mjs +4 -4
- package/lib/webhooks.mjs +68 -0
- package/package.json +10 -2
- package/public/app.js +2 -0
- package/public/index.html +2 -0
- package/public/setup.css +97 -0
- package/public/setup.html +135 -0
- package/public/setup.js +289 -0
- package/public/style.css +4 -0
- package/server.mjs +24 -7
package/lib/platforms.mjs
CHANGED
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
// talker/planner enough to act like a real product specialist for that product:
|
|
3
3
|
// the sign-up → dashboard path, a clean short "journey" for the live progress
|
|
4
4
|
// tracker, and a small knowledge base the agent can retrieve from (RAG).
|
|
5
|
+
//
|
|
6
|
+
// These are built-in samples. Your own products go in playbook files (see
|
|
7
|
+
// lib/playbooks.mjs and docs/setup/customize.md); they're merged in below.
|
|
5
8
|
|
|
6
|
-
|
|
9
|
+
import { loadPlaybooks, mergePlaybooks, PLAYBOOKS_DIR } from "./playbooks.mjs";
|
|
10
|
+
|
|
11
|
+
export const BUILTIN_PLATFORMS = [
|
|
7
12
|
{
|
|
8
13
|
id: "todoist", name: "Todoist", url: "https://app.todoist.com",
|
|
9
14
|
category: "Productivity", accent: "#E44332",
|
|
@@ -194,6 +199,15 @@ export const PLATFORMS = [
|
|
|
194
199
|
},
|
|
195
200
|
];
|
|
196
201
|
|
|
202
|
+
const custom = loadPlaybooks();
|
|
203
|
+
for (const p of custom.problems) {
|
|
204
|
+
for (const e of p.errors) console.warn(` [playbooks] ${p.file} skipped: ${e}`);
|
|
205
|
+
for (const w of p.warnings) console.warn(` [playbooks] ${p.file}: ${w}`);
|
|
206
|
+
}
|
|
207
|
+
const hideBuiltins = /^(off|0|false|no)$/i.test(process.env.BUILTIN_PLAYBOOKS || "");
|
|
208
|
+
export const PLATFORMS = mergePlaybooks(BUILTIN_PLATFORMS, custom.playbooks, { hideBuiltins });
|
|
209
|
+
export const CUSTOM_PLAYBOOKS = { count: custom.playbooks.length, dir: PLAYBOOKS_DIR };
|
|
210
|
+
|
|
197
211
|
export function platformById(id) { return PLATFORMS.find((p) => p.id === id) || null; }
|
|
198
212
|
// Trimmed shape for the onboarding picker in the client (demo-only entries hidden).
|
|
199
213
|
// Demo-only (fictional) products are included, flagged, so demo mode can select
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Playbooks: the products Ava can onboard people onto, as JSON files you own.
|
|
2
|
+
//
|
|
3
|
+
// Put one file per product in PLAYBOOKS_DIR (default ~/.ava-engine/playbooks), for
|
|
4
|
+
// example acme.json. A file whose id matches a built-in product replaces it.
|
|
5
|
+
// BUILTIN_PLAYBOOKS=off hides the built-in sample products, so the lobby shows only
|
|
6
|
+
// yours. Files are checked when Ava starts: a broken one is skipped with a message
|
|
7
|
+
// saying what to fix, and never stops the server. `ava-engine playbooks` checks
|
|
8
|
+
// them without starting anything.
|
|
9
|
+
|
|
10
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
const home = (p) => p.replace(/^~(?=$|[\\/])/, homedir()); // .env files don't expand ~
|
|
15
|
+
export const PLAYBOOKS_DIR = home(process.env.PLAYBOOKS_DIR || join(process.env.AVA_HOME || join(homedir(), ".ava-engine"), "playbooks"));
|
|
16
|
+
|
|
17
|
+
const ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
18
|
+
const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
19
|
+
const KNOWN = new Set(["id", "name", "url", "category", "accent", "tagline", "firstWin", "path", "optional", "journey", "etaMin", "knowledge", "demoOnly", "$schema"]);
|
|
20
|
+
|
|
21
|
+
/** Starter file for `ava-engine playbooks new <id>`. */
|
|
22
|
+
export function template(id) {
|
|
23
|
+
return {
|
|
24
|
+
id, name: "Acme", url: "https://app.acme.example",
|
|
25
|
+
category: "Your product", accent: "#4F46E5",
|
|
26
|
+
tagline: "One line on what the product does and why setup takes a hand.",
|
|
27
|
+
firstWin: "the moment they get value, e.g. their first report open on the dashboard",
|
|
28
|
+
path: [
|
|
29
|
+
"sign in or sign up",
|
|
30
|
+
"create a workspace (name it, keep the Free plan)",
|
|
31
|
+
"connect a data source from the Sources page",
|
|
32
|
+
"open the dashboard and see the first report",
|
|
33
|
+
],
|
|
34
|
+
optional: "Things that can wait until after the first win, e.g. inviting teammates.",
|
|
35
|
+
journey: ["Sign in", "Create a workspace", "Connect a source", "See your first report"],
|
|
36
|
+
etaMin: 5,
|
|
37
|
+
knowledge: [
|
|
38
|
+
{ keywords: "workspace create name plan free", text: "Workspaces hold everything. The Free plan is enough to start and can be upgraded later from Settings → Billing." },
|
|
39
|
+
{ keywords: "source connect csv database import", text: "Sources → Add source connects a database or uploads a CSV. The first sync takes about a minute." },
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const str = (v, max) => typeof v === "string" && v.trim().length > 0 && v.length <= max;
|
|
45
|
+
const strList = (v, maxItems, maxLen) => Array.isArray(v) && v.length <= maxItems && v.every((s) => str(s, maxLen));
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check one playbook. Returns { playbook } (normalized, ready to use) or { errors: [...] }.
|
|
49
|
+
* `fallbackId` is the file name, used when the JSON has no id.
|
|
50
|
+
*/
|
|
51
|
+
export function validatePlaybook(raw, fallbackId) {
|
|
52
|
+
const errors = [], warnings = [];
|
|
53
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { errors: ["the file must contain one JSON object"], warnings };
|
|
54
|
+
const id = raw.id ?? fallbackId;
|
|
55
|
+
if (!ID.test(String(id ?? ""))) errors.push(`"id" must be lowercase letters, digits and dashes, up to 40 characters (got ${JSON.stringify(id)})`);
|
|
56
|
+
if (!str(raw.name, 60)) errors.push(`"name" is required (up to 60 characters)`);
|
|
57
|
+
if (raw.url !== undefined && !(str(raw.url, 300) && /^https?:\/\/[^\s]+$/i.test(raw.url))) errors.push(`"url" must start with http:// or https://`);
|
|
58
|
+
if (raw.accent !== undefined && !HEX.test(String(raw.accent))) errors.push(`"accent" must be a hex colour like "#4F46E5"`);
|
|
59
|
+
for (const k of ["category", "tagline", "firstWin", "optional"]) if (raw[k] !== undefined && !str(raw[k], 300)) errors.push(`"${k}" must be text (up to 300 characters)`);
|
|
60
|
+
if (!strList(raw.journey, 10, 60) || raw.journey.length === 0) errors.push(`"journey" is required: 1 to 10 short step names (up to 60 characters each), shown in the progress tracker`);
|
|
61
|
+
if (raw.path !== undefined && !strList(raw.path, 12, 400)) errors.push(`"path" must be a list of up to 12 how-to lines (up to 400 characters each)`);
|
|
62
|
+
if (raw.etaMin !== undefined && !(Number.isInteger(raw.etaMin) && raw.etaMin >= 1 && raw.etaMin <= 120)) errors.push(`"etaMin" must be a whole number of minutes from 1 to 120`);
|
|
63
|
+
if (raw.demoOnly !== undefined && typeof raw.demoOnly !== "boolean") errors.push(`"demoOnly" must be true or false`);
|
|
64
|
+
let knowledge = [];
|
|
65
|
+
if (raw.knowledge !== undefined) {
|
|
66
|
+
if (!Array.isArray(raw.knowledge) || raw.knowledge.length > 200) errors.push(`"knowledge" must be a list of up to 200 entries`);
|
|
67
|
+
else raw.knowledge.forEach((e, i) => {
|
|
68
|
+
const kw = Array.isArray(e?.keywords) ? e.keywords.join(" ") : e?.keywords ?? e?.k;
|
|
69
|
+
if (!str(e?.text, 1000)) errors.push(`knowledge[${i}]: "text" is required (up to 1000 characters)`);
|
|
70
|
+
else if (!str(kw, 300)) errors.push(`knowledge[${i}]: "keywords" is required: the words a question would use, e.g. "invoice export csv download"`);
|
|
71
|
+
else knowledge.push({ k: kw.toLowerCase(), text: e.text });
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
for (const k of Object.keys(raw)) if (!KNOWN.has(k)) warnings.push(`unknown field "${k}" is ignored`);
|
|
75
|
+
if (errors.length) return { errors, warnings };
|
|
76
|
+
const { name, url, category, accent, tagline, firstWin, optional, etaMin, demoOnly } = raw;
|
|
77
|
+
return {
|
|
78
|
+
warnings,
|
|
79
|
+
playbook: {
|
|
80
|
+
id, name, url, category: category || "Your product", accent, tagline: tagline || "", firstWin: firstWin || "the first step done",
|
|
81
|
+
path: raw.path || [], optional, journey: raw.journey, etaMin: etaMin || null, knowledge, ...(demoOnly ? { demoOnly: true } : {}),
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Read every *.json in `dir`. Returns { playbooks, problems: [{file, errors, warnings}] }. */
|
|
87
|
+
export function loadPlaybooks(dir = PLAYBOOKS_DIR) {
|
|
88
|
+
const playbooks = [], problems = [];
|
|
89
|
+
if (!existsSync(dir)) return { playbooks, problems, dir };
|
|
90
|
+
const seen = new Map();
|
|
91
|
+
for (const file of readdirSync(dir).filter((f) => f.endsWith(".json")).sort()) {
|
|
92
|
+
let raw;
|
|
93
|
+
try { raw = JSON.parse(readFileSync(join(dir, file), "utf8")); }
|
|
94
|
+
catch (e) { problems.push({ file, errors: [`not valid JSON: ${e.message}`], warnings: [] }); continue; }
|
|
95
|
+
const r = validatePlaybook(raw, file.replace(/\.json$/, ""));
|
|
96
|
+
if (r.playbook && seen.has(r.playbook.id)) r.errors = [`id "${r.playbook.id}" is already used by ${seen.get(r.playbook.id)}`];
|
|
97
|
+
if (r.errors?.length) { problems.push({ file, errors: r.errors, warnings: r.warnings }); continue; }
|
|
98
|
+
if (r.warnings.length) problems.push({ file, errors: [], warnings: r.warnings });
|
|
99
|
+
seen.set(r.playbook.id, file);
|
|
100
|
+
playbooks.push({ ...r.playbook, source: file });
|
|
101
|
+
}
|
|
102
|
+
return { playbooks, problems, dir };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Built-ins plus your files: yours replace a built-in with the same id. */
|
|
106
|
+
export function mergePlaybooks(builtins, custom, { hideBuiltins = false } = {}) {
|
|
107
|
+
const ids = new Set(custom.map((p) => p.id));
|
|
108
|
+
// Demo-only built-ins stay: demo mode needs them and the lobby never lists them.
|
|
109
|
+
const kept = builtins.filter((b) => !ids.has(b.id) && (!hideBuiltins || b.demoOnly));
|
|
110
|
+
return [...custom, ...kept];
|
|
111
|
+
}
|
package/lib/spend.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Daily spending cap across every session: DAILY_BUDGET_USD (unset or 0 = no cap).
|
|
2
|
+
//
|
|
3
|
+
// Counts what OpenRouter bills for model calls (its reported usage.cost, or our
|
|
4
|
+
// estimate), per UTC day. Once today's total reaches the cap, new paid calls are
|
|
5
|
+
// refused until midnight UTC. Calls already in flight finish, so the total can
|
|
6
|
+
// overshoot by a few cents. Speech providers (Deepgram, ElevenLabs, Hume) bill
|
|
7
|
+
// separately and aren't counted.
|
|
8
|
+
//
|
|
9
|
+
// The total is saved to SPEND_FILE (default ~/.ava-engine/spend.json) so a restart
|
|
10
|
+
// doesn't reset it. Nothing is written when no cap is set.
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
|
|
16
|
+
export const DAILY_BUDGET_USD = Math.max(0, Number(process.env.DAILY_BUDGET_USD) || 0);
|
|
17
|
+
const FILE = process.env.SPEND_FILE || join(process.env.AVA_HOME || join(homedir(), ".ava-engine"), "spend.json");
|
|
18
|
+
|
|
19
|
+
const today = () => new Date().toISOString().slice(0, 10);
|
|
20
|
+
let ledger = load();
|
|
21
|
+
|
|
22
|
+
function load() {
|
|
23
|
+
if (!DAILY_BUDGET_USD) return { day: today(), usd: 0 };
|
|
24
|
+
try {
|
|
25
|
+
const j = JSON.parse(readFileSync(FILE, "utf8"));
|
|
26
|
+
if (typeof j.day === "string" && Number.isFinite(j.usd) && j.usd >= 0) return { day: j.day, usd: j.usd };
|
|
27
|
+
} catch { /* first run or unreadable: start from zero */ }
|
|
28
|
+
return { day: today(), usd: 0 };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function current() {
|
|
32
|
+
const d = today();
|
|
33
|
+
if (ledger.day !== d) ledger = { day: d, usd: 0 };
|
|
34
|
+
return ledger;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Add the cost of a finished call to today's total. */
|
|
38
|
+
export function recordSpend(usd) {
|
|
39
|
+
if (!(usd > 0)) return;
|
|
40
|
+
const l = current();
|
|
41
|
+
l.usd += usd;
|
|
42
|
+
if (!DAILY_BUDGET_USD) return;
|
|
43
|
+
try {
|
|
44
|
+
if (!existsSync(dirname(FILE))) mkdirSync(dirname(FILE), { recursive: true });
|
|
45
|
+
writeFileSync(FILE, JSON.stringify(l));
|
|
46
|
+
} catch (e) { console.warn(" [spend] couldn't save the daily total:", e.message); }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True once today's spend has reached DAILY_BUDGET_USD. */
|
|
50
|
+
export function dailyCapReached() {
|
|
51
|
+
return DAILY_BUDGET_USD > 0 && current().usd >= DAILY_BUDGET_USD;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Today's total, for logs and tests. */
|
|
55
|
+
export function spentToday() { return current().usd; }
|
|
56
|
+
|
|
57
|
+
export class DailyCapError extends Error {
|
|
58
|
+
constructor() { super("daily spending cap reached"); this.status = 402; this.code = "daily_cap"; }
|
|
59
|
+
}
|
package/lib/state.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// In-memory session store for the flexible agent.
|
|
2
2
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { emitEvent } from "./webhooks.mjs";
|
|
4
5
|
|
|
5
6
|
const sessions = new Map();
|
|
6
7
|
const byKey = new Map(); // specialist key → session id (see mintSpecialistKey)
|
|
@@ -13,16 +14,24 @@ const MAX_SESSIONS = Number(process.env.MAX_SESSIONS) || 50;
|
|
|
13
14
|
const MAX_AGE_MS = 6 * 3600 * 1000;
|
|
14
15
|
const IDLE_MS = 30 * 60 * 1000;
|
|
15
16
|
|
|
16
|
-
function drop(id) {
|
|
17
|
+
function drop(id, reason) {
|
|
17
18
|
const s = sessions.get(id);
|
|
18
|
-
if (s
|
|
19
|
+
if (!s) return;
|
|
20
|
+
if (s.specialistKey) revokeSpecialistKey(s);
|
|
19
21
|
sessions.delete(id);
|
|
22
|
+
const o = s.ours, wf = o.workflow;
|
|
23
|
+
emitEvent("session.ended", {
|
|
24
|
+
sessionId: id, reason, mode: o.mode, platform: o.platform ? { id: o.platform.id, name: o.platform.name } : null,
|
|
25
|
+
durationSec: Math.round((Date.now() - s.createdAt) / 1000), turns: s.score.ours.turns, costUsd: Math.round(s.score.ours.cost * 1e4) / 1e4,
|
|
26
|
+
workflow: wf ? { title: wf.title, done: wf.steps.filter((x) => x.status === "done").length, total: wf.steps.length, completed: !!wf.complete } : null,
|
|
27
|
+
handoffRequested: !!o.handoff?.requested,
|
|
28
|
+
});
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
/** Room for one more session? Expired and idle sessions are pruned first. */
|
|
23
32
|
export function hasCapacity() {
|
|
24
33
|
const now = Date.now();
|
|
25
|
-
for (const [id, s] of sessions) { if (now - s.createdAt > MAX_AGE_MS || now - s.lastActive > IDLE_MS) drop(id); }
|
|
34
|
+
for (const [id, s] of sessions) { if (now - s.createdAt > MAX_AGE_MS || now - s.lastActive > IDLE_MS) drop(id, "expired"); }
|
|
26
35
|
return sessions.size < MAX_SESSIONS;
|
|
27
36
|
}
|
|
28
37
|
|
|
@@ -39,6 +48,7 @@ export function newSession(opts = {}) {
|
|
|
39
48
|
lastActive: Date.now(),
|
|
40
49
|
specialistKey: null, // capability for the human-specialist console; minted on handoff
|
|
41
50
|
ours: {
|
|
51
|
+
sessionId: id, // for the events this session's agents report (webhooks)
|
|
42
52
|
mode, // "flexible" | "onboarding"
|
|
43
53
|
platform, // onboarding target {id,name,url,firstWin,path,...} or null
|
|
44
54
|
screenState: null, // last structured screen state {app,page,elements,state,v}
|
|
@@ -108,8 +118,7 @@ export function revokeSpecialistKey(sess) {
|
|
|
108
118
|
export function endSession(id) {
|
|
109
119
|
const s = sessions.get(id);
|
|
110
120
|
if (!s) return false;
|
|
111
|
-
|
|
112
|
-
drop(id);
|
|
121
|
+
drop(id, "hangup");
|
|
113
122
|
return true;
|
|
114
123
|
}
|
|
115
124
|
export function getSessionBySpecialistKey(key) {
|
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
|
-
|
|
54
|
-
kokoroTTS = await
|
|
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/lib/webhooks.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Event webhooks: tell your own systems what happens on calls.
|
|
2
|
+
//
|
|
3
|
+
// WEBHOOK_URL one or more URLs (comma-separated) that receive every event as JSON
|
|
4
|
+
// WEBHOOK_SECRET signs each delivery: X-Ava-Signature: sha256=HMAC(secret, `${timestamp}.${body}`)
|
|
5
|
+
// SLACK_WEBHOOK_URL a Slack incoming webhook; posts when a customer asks for a person
|
|
6
|
+
// PUBLIC_URL where specialists open Ava (for the links in those messages)
|
|
7
|
+
//
|
|
8
|
+
// Events: session.started, step.completed, workflow.completed, handoff.requested,
|
|
9
|
+
// session.ended. Delivery is fire-and-forget with one retry, so a slow or broken
|
|
10
|
+
// endpoint never holds up a call.
|
|
11
|
+
|
|
12
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
13
|
+
|
|
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;
|
|
18
|
+
|
|
19
|
+
export const WEBHOOKS_ENABLED = URLS.length > 0 || !!SLACK;
|
|
20
|
+
|
|
21
|
+
/** The URL a specialist opens to join a call. */
|
|
22
|
+
export function specialistUrl(key) {
|
|
23
|
+
const base = (process.env.PUBLIC_URL || `http://localhost:${Number(process.env.PORT) || 5190}`).replace(/\/+$/, "");
|
|
24
|
+
return `${base}/specialist?key=${encodeURIComponent(key)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function post(url, body, headers, label) {
|
|
28
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
29
|
+
try {
|
|
30
|
+
const r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "User-Agent": "ava-engine", ...headers }, body, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
31
|
+
if (r.ok || (r.status >= 400 && r.status < 500 && r.status !== 429)) {
|
|
32
|
+
if (!r.ok) console.warn(` [${label}] endpoint refused the event (${r.status})`);
|
|
33
|
+
return r.ok;
|
|
34
|
+
}
|
|
35
|
+
} catch { /* network error or timeout: retry once */ }
|
|
36
|
+
if (attempt === 0) await new Promise((r) => setTimeout(r, RETRY_MS));
|
|
37
|
+
}
|
|
38
|
+
console.warn(` [${label}] delivery failed after a retry`); // never log the URL: Slack URLs are secrets
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Signature headers for a delivery body (exported for tests and for receivers to copy). */
|
|
43
|
+
export function sign(body, timestamp, secret = SECRET) {
|
|
44
|
+
return "sha256=" + createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function slackText(type, data) {
|
|
48
|
+
if (type !== "handoff.requested") return null;
|
|
49
|
+
const where = data.platform ? ` on ${data.platform.name}` : "";
|
|
50
|
+
const step = data.workflow?.currentStep ? `\n*Stuck on:* ${data.workflow.currentStep} (${data.workflow.done}/${data.workflow.total} steps done)` : "";
|
|
51
|
+
const said = data.lastUserMessage ? `\n*Last said:* “${data.lastUserMessage}”` : "";
|
|
52
|
+
return `:raising_hand: A customer${where} asked to talk to a person.${step}${said}\n<${data.specialistUrl}|Join the call>`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Send an event to every configured endpoint. Returns a promise tests can await; callers needn't. */
|
|
56
|
+
export function emitEvent(type, data) {
|
|
57
|
+
if (!WEBHOOKS_ENABLED) return Promise.resolve();
|
|
58
|
+
const event = { id: randomUUID(), type, createdAt: new Date().toISOString(), data };
|
|
59
|
+
const body = JSON.stringify(event);
|
|
60
|
+
const jobs = URLS.map((url) => {
|
|
61
|
+
const ts = String(Math.floor(Date.now() / 1000));
|
|
62
|
+
const headers = { "X-Ava-Event": type, "X-Ava-Delivery": event.id, "X-Ava-Timestamp": ts, ...(SECRET ? { "X-Ava-Signature": sign(body, ts) } : {}) };
|
|
63
|
+
return post(url, body, headers, "webhook");
|
|
64
|
+
});
|
|
65
|
+
const text = SLACK && slackText(type, data);
|
|
66
|
+
if (text) jobs.push(post(SLACK, JSON.stringify({ text }), {}, "slack"));
|
|
67
|
+
return Promise.allSettled(jobs);
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ava-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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
|
}
|
package/public/app.js
CHANGED
|
@@ -25,6 +25,8 @@ let pip = null;
|
|
|
25
25
|
try {
|
|
26
26
|
const h = await (await fetch("/api/health")).json();
|
|
27
27
|
S.tts = h.tts || { enabled: false };
|
|
28
|
+
// Demo mode (no API key): say so up front, in the lobby and on the call.
|
|
29
|
+
if (h.mock) { $("obDemo").hidden = false; $("demoPill").hidden = false; }
|
|
28
30
|
S.stt = !!(h.stt && h.stt.enabled);
|
|
29
31
|
S.sttStream = !!(h.stt && h.stt.streaming); // offline sherpa streaming ASR on the server
|
|
30
32
|
// speech-recognition engine picker (green room)
|
package/public/index.html
CHANGED
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
<div class="ob-header">
|
|
48
48
|
<h1>Start a session</h1>
|
|
49
49
|
<p>Choose how Ava should work, then check your audio before connecting.</p>
|
|
50
|
+
<p class="ob-demo" id="obDemo" hidden><b>Demo mode.</b> Replies are canned until you add an OpenRouter key: run <code>npx ava-engine setup</code>.</p>
|
|
50
51
|
</div>
|
|
51
52
|
|
|
52
53
|
<div class="ob-section">
|
|
@@ -129,6 +130,7 @@
|
|
|
129
130
|
<span class="vdiv"></span>
|
|
130
131
|
<span class="apptitle" id="apptitle">Flexible Agent</span>
|
|
131
132
|
<span class="mono meta hide-narrow" id="topsub">screen-aware assistant</span>
|
|
133
|
+
<span id="demoPill" class="pill demo" hidden title="Replies are canned until an OpenRouter key is added">DEMO MODE</span>
|
|
132
134
|
<span id="livepill" class="pill standby"><span class="dot"></span><span id="livepilltext">STANDBY</span></span>
|
|
133
135
|
<div class="grow"></div>
|
|
134
136
|
<span id="privacy" class="privacypill" hidden>
|
package/public/setup.css
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
.connect { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
|
|
58
|
+
.or { display: flex; align-items: center; gap: 12px; margin: 10px 0 2px; color: var(--meta); font-size: 14px; }
|
|
59
|
+
.or::before, .or::after { content: ""; flex: 1; height: 1px; background: var(--line); }
|
|
60
|
+
.status { margin: 0; min-height: 22px; font-size: 14.5px; }
|
|
61
|
+
.status.ok { color: var(--ok); } .status.err { color: var(--err); }
|
|
62
|
+
.banner { padding: 14px 18px; border-radius: 12px; margin-bottom: 24px; }
|
|
63
|
+
.banner.error { background: var(--err-tint); color: var(--err); }
|
|
64
|
+
|
|
65
|
+
.job { background: #fff; border: 1.5px solid var(--line); border-radius: 12px; padding: 14px 16px; margin-bottom: 12px; }
|
|
66
|
+
.job-head { display: flex; justify-content: space-between; font-size: 14.5px; margin-bottom: 10px; font-weight: 500; }
|
|
67
|
+
.job-head span:last-child { font-family: "Plex Mono", monospace; color: var(--meta); }
|
|
68
|
+
.bar { height: 8px; background: var(--soft); border-radius: 99px; overflow: hidden; }
|
|
69
|
+
.bar i { display: block; height: 100%; width: 0; background: var(--accent); border-radius: 99px; transition: width .3s; }
|
|
70
|
+
.job.done .bar i { background: var(--ok); } .job.error .bar i { background: var(--err); }
|
|
71
|
+
|
|
72
|
+
.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; }
|
|
73
|
+
.summary dt { color: var(--meta); font-size: 14.5px; } .summary dd { margin: 0; font-weight: 500; word-break: break-word; }
|
|
74
|
+
|
|
75
|
+
.nav { display: flex; justify-content: space-between; align-items: center; margin-top: 32px; gap: 12px; }
|
|
76
|
+
.btn { height: 46px; padding: 0 22px; border-radius: 99px; font: 600 15px "Inter Tight", sans-serif; cursor: pointer; border: 1.5px solid transparent; }
|
|
77
|
+
.btn.primary { background: var(--ink); color: #fff; }
|
|
78
|
+
.btn.primary:hover { background: #24242C; }
|
|
79
|
+
.btn.primary:disabled { background: #B9B9C3; cursor: not-allowed; }
|
|
80
|
+
.btn.ghost { background: #fff; color: var(--ink); border-color: var(--line); }
|
|
81
|
+
.btn.ghost:hover { border-color: #CFCFD8; }
|
|
82
|
+
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
|
83
|
+
|
|
84
|
+
.launching { text-align: center; padding-top: 12vh; }
|
|
85
|
+
.launching .lead { margin-inline: auto; }
|
|
86
|
+
.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; }
|
|
87
|
+
@keyframes pulse { 50% { transform: scale(1.06); box-shadow: 0 0 120px rgba(124,108,246,.75); } }
|
|
88
|
+
@media (prefers-reduced-motion: reduce) { .orb { animation: none; } .bar i { transition: none; } }
|
|
89
|
+
|
|
90
|
+
@media (max-width: 760px) {
|
|
91
|
+
.shell { grid-template-columns: 1fr; }
|
|
92
|
+
.rail { padding: 20px; gap: 16px; }
|
|
93
|
+
.steps { flex-direction: row; overflow-x: auto; gap: 4px; }
|
|
94
|
+
.steps li { padding: 6px 8px; font-size: 0; gap: 0; } .steps li.current { font-size: 14px; gap: 8px; }
|
|
95
|
+
.rail-note { display: none; }
|
|
96
|
+
.panel { padding: 32px 16px; }
|
|
97
|
+
}
|