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/README.md CHANGED
@@ -58,20 +58,37 @@ Requires **Node 22.9+** and desktop **Chrome**. One command installs Ava and wal
58
58
  npx ava-engine
59
59
  ```
60
60
 
61
- Set up **in your browser** (a step-by-step page) or **in the terminal**; both do the same:
61
+ First it asks where to set up: **in your browser** (a guided page) or **in the terminal**. Both do the same:
62
62
  1. check your OpenRouter key (or look around with no keys, in MOCK mode);
63
63
  2. pick Ava's voice, and download the free offline voice (Kokoro, ~90 MB) with progress;
64
64
  3. download offline speech recognition, also with progress;
65
65
  4. protect Ava with a password if other people will use it;
66
- 5. start Ava and open **http://localhost:5190/app**.
66
+ 5. start Ava and open your **dashboard** at **http://localhost:5190/dashboard**.
67
67
 
68
68
  After that, `npx ava-engine` just starts Ava.
69
69
 
70
+ ### Run it from the dashboard
71
+
72
+ | | |
73
+ | --- | --- |
74
+ | **Overview** | Live status, today's calls and spend, who's waiting for a person, and a getting-started checklist |
75
+ | **Inbox** | Customers who asked for a person, live. A sound and a notification when someone new is waiting; **Pick up** marks it yours and opens the specialist console |
76
+ | **Calls** | Live calls (join a customer who asked for a person) and history with search, filters and CSV export. Each call has a page: outcome, journey, length, cost, and (if you turn transcripts on) the conversation and recap |
77
+ | **Analytics** | How many finish onboarding, the step where people get stuck (with a hint on what to fix), how often they need a person, and cost per finished onboarding. By period, product and link |
78
+ | **Share links** | One link per product and case, e.g. a trial welcome email or your app's help menu. Customers land on a simple start page with no pickers and no console. Pause, expire or cap a link per day. **Embed** gives you one line of HTML for a "Talk to Ava" button in your own product |
79
+ | **Playbooks** | Describe your product in a form, or **Draft with AI** from your docs page. Or install one from the shared [playbook library](https://github.com/Mr-vero/ava-playbooks) and adapt it. Live on new calls as soon as you save |
80
+ | **Team** | Everyone signs in with their own account: owners (everything), editors (links, playbooks, calls) and specialists (the inbox). API keys for n8n, Zapier or your backend: everything the dashboard does, over [the API](docs/API.md). Locked out? `npx ava-engine team reset you@company.com` |
81
+ | **Settings** | Edit keys, models, voice, password, public address, limits and webhooks, applied without a restart (host, port and proxy mode need one). Connect OpenRouter from here too. Secrets are never shown back |
82
+
83
+ `/app` stays as your own test bench. Preselect it with `/app?mode=onboarding&product=<playbook id>`.
84
+
70
85
  | Command | What it does |
71
86
  | --- | --- |
72
87
  | `npx ava-engine setup` | Re-run setup to change keys, voice, password or port (`--web` for the browser, `--terminal` for here) |
73
88
  | `npx ava-engine doctor` | Check everything and get a fix for anything that's wrong |
74
89
  | `npx ava-engine models` | Download speech models or the Kokoro voice (`models kokoro`) |
90
+ | `npx ava-engine playbooks` | Check your playbook files (`playbooks new <id>` writes a starter) |
91
+ | `npx ava-engine team` | List team accounts (`team add <email>`, `team reset <email>`) |
75
92
  | `npx ava-engine start --port 8080` | Start on another port |
76
93
 
77
94
  Settings live in `~/.ava-engine` (set `AVA_HOME` to move them). **Watch the demo locally** at `http://localhost:5190/app?demo=simple`.
@@ -105,6 +122,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
105
122
  | šŸŽ§ | [**Speech-to-text**](docs/setup/speech-to-text.md) | Offline sherpa-onnx models, English or multilingual |
106
123
  | 🧩 | [**Customizing**](docs/setup/customize.md) | Products in the onboarding catalogue, persona, look |
107
124
  | šŸš€ | [**Deploying**](DEPLOY.md) | A Cloudflare tunnel, Fly.io or any Docker host |
125
+ | šŸ”Œ | [**API**](docs/API.md) | Drive Ava from scripts and tools with an API key |
108
126
  | šŸ“š | [**Reference**](docs/REFERENCE.md) | Every route, file and setting |
109
127
 
110
128
  ---
package/lib/admin.mjs ADDED
@@ -0,0 +1,284 @@
1
+ // The dashboard's API (/api/admin/*): overview, calls, share links, playbooks and
2
+ // settings. Only for the operator: the server checks access before calling in.
3
+
4
+ import { readFileSync } from "node:fs";
5
+ import { join, dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { liveSessions, getSession } from "./state.mjs";
8
+ import { listUsers, createUser, updateUser, setPassword, deleteUser, hasUsers, getUser, verifyLogin, suggestPassword, ROLES, listKeys, createKey, deleteKey } from "./team.mjs";
9
+ import { analytics } from "./analytics.mjs";
10
+ import { recentCalls, callsToday, getCall, deleteCall, getTranscript, transcriptsOn, deleteAllTranscripts } from "./calls.mjs";
11
+ import { listLinks, getLink, createLink, updateLink, deleteLink, validateLink, LINKS_FILE } from "./links.mjs";
12
+ import { PLATFORMS, platformById, playbooksAdminView, savePlaybook, deletePlaybook } from "./platforms.mjs";
13
+ import { validatePlaybook, template } from "./playbooks.mjs";
14
+ import { DAILY_BUDGET_USD, spentToday, dailyCapReached } from "./spend.mjs";
15
+ import { emitEvent, WEBHOOK_INFO } from "./webhooks.mjs";
16
+ import { MOCK } from "./openrouter.mjs";
17
+ import { MODELS, PROVIDER } from "./models.mjs";
18
+ import { AUTH_ENABLED, refreshAuth, sessionCookieFor, logoutCookie } from "./guard.mjs";
19
+ import { settingsView, updateSettings } from "./settings.mjs";
20
+ import { libraryView, installFromLibrary } from "./library.mjs";
21
+ import { draftPlaybook } from "./draft.mjs";
22
+ import { pkce, authUrl, exchangeCode, callbackPage, FLOW_TIMEOUT_MS } from "./cli/openrouter-oauth.mjs";
23
+
24
+ const VERSION = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
25
+ const round = (n, d = 4) => Math.round(n * 10 ** d) / 10 ** d;
26
+ const productExists = (id) => PLATFORMS.some((p) => p.id === id);
27
+
28
+ /**
29
+ * ctx: { send, readBody, LOCAL_ONLY, HOST, PORT, sessionBudget(), voice(), speech() }
30
+ * Returns a handler(req, res, url) for /api/admin/* paths.
31
+ */
32
+ export function createAdmin(ctx) {
33
+ const { send, readBody } = ctx;
34
+ const publicUrl = () => (process.env.PUBLIC_URL || "").replace(/\/+$/, "") || null;
35
+
36
+ function status() {
37
+ return {
38
+ version: VERSION, mock: MOCK,
39
+ access: { password: AUTH_ENABLED, localOnly: ctx.LOCAL_ONLY, publicUrl: publicUrl() },
40
+ spend: { today: round(spentToday()), dailyCap: DAILY_BUDGET_USD || null, capReached: !MOCK && dailyCapReached(), perCall: ctx.sessionBudget() },
41
+ };
42
+ }
43
+
44
+ function liveView() {
45
+ return liveSessions().map((s) => ({ ...s, handoff: s.handoff ? { state: s.handoff.state, since: s.handoff.since, pickedUpBy: s.handoff.pickedUpBy?.name || null, joinPath: s.handoff.specialistKey ? `/specialist?key=${encodeURIComponent(s.handoff.specialistKey)}` : null } : null }));
46
+ }
47
+
48
+ function overview() {
49
+ const live = liveView(), done = callsToday();
50
+ const all = [...done, ...live];
51
+ const view = playbooksAdminView();
52
+ const links = listLinks();
53
+ return {
54
+ status: status(),
55
+ today: {
56
+ calls: all.length, live: live.length,
57
+ waiting: live.filter((s) => s.handoff && s.handoff.state !== "joined").length,
58
+ minutes: Math.round(all.reduce((a, c) => a + (c.durationSec || 0), 0) / 60),
59
+ completed: all.filter((c) => c.workflow?.completed).length,
60
+ handoffs: all.filter((c) => c.handoffRequested).length,
61
+ },
62
+ live, recent: recentCalls(8),
63
+ checklist: [
64
+ { id: "key", done: !MOCK, label: "Connect OpenRouter for real answers", hint: "npx ava-engine setup" },
65
+ { id: "playbook", done: view.playbooks.some((p) => p.source === "yours"), label: "Describe your product in a playbook" },
66
+ { id: "link", done: links.length > 0, label: "Create a share link for your customers" },
67
+ { id: "access", done: AUTH_ENABLED || false, label: "Let customers reach Ava (password + public address)", hint: "npx ava-engine setup → ā€œMy team or customersā€" },
68
+ { id: "events", done: WEBHOOK_INFO.endpoints > 0 || WEBHOOK_INFO.slack, label: "Get handoff alerts in Slack or your own systems", hint: "SLACK_WEBHOOK_URL / WEBHOOK_URL" },
69
+ ],
70
+ };
71
+ }
72
+
73
+ function linkView(l) {
74
+ const p = l.product ? platformById(l.product) : null;
75
+ return { ...l, product: l.product, productName: p ? p.name : null, productAccent: p?.accent || null, productMissing: !!l.product && !p, path: `/c/${l.id}` };
76
+ }
77
+
78
+ function settings() {
79
+ return {
80
+ ...status(), ...settingsView(),
81
+ ai: { models: MODELS, providers: PROVIDER },
82
+ voice: ctx.voice(), speech: ctx.speech(),
83
+ server: { host: ctx.HOST || "all network interfaces", port: ctx.PORT, trustProxy: process.env.TRUST_PROXY === "1" },
84
+ limits: { perCallUsd: ctx.sessionBudget(), dailyUsd: DAILY_BUDGET_USD || null, maxSessions: Number(process.env.MAX_SESSIONS) || 50, sessionsPerMin: Number(process.env.RATE_SESSIONS_PER_MIN) || 10, apiPerMin: Number(process.env.RATE_API_PER_MIN) || 240 },
85
+ events: WEBHOOK_INFO,
86
+ files: { config: process.env.AVA_CONFIG_FILE || null, playbooks: playbooksAdminView().dir, links: LINKS_FILE },
87
+ };
88
+ }
89
+
90
+ // "Connect OpenRouter" from the dashboard: same PKCE flow as setup. OpenRouter
91
+ // sends the browser back to /dashboard/openrouter-callback, which the server
92
+ // routes to finishOpenRouter() (a cross-site redirect carries no login cookie,
93
+ // so the single-use state proves the request is ours).
94
+ let oauth = null;
95
+ function startOpenRouter(origin) {
96
+ const { verifier, challenge, state } = pkce();
97
+ oauth = { verifier, state, at: Date.now() };
98
+ return authUrl({ callbackUrl: `${origin}/dashboard/openrouter-callback`, challenge, state });
99
+ }
100
+ async function finishOpenRouter(req, res, url) {
101
+ const page = (ok) => send(res, ok ? 200 : 400, callbackPage(ok, { cssHref: "/dashboard.css" }).replace("You can close this tab and go back to setup.", "Ava is using your new key. You can close this tab."), { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
102
+ const f = oauth;
103
+ if (!f || Date.now() - f.at > FLOW_TIMEOUT_MS || url.searchParams.get("state") !== f.state || !url.searchParams.get("code")) return page(false);
104
+ oauth = null; // single use
105
+ try {
106
+ const key = await exchangeCode({ code: url.searchParams.get("code"), verifier: f.verifier });
107
+ const r = updateSettings({ OPENROUTER_API_KEY: key });
108
+ return page(!r.errors);
109
+ } catch { return page(false); }
110
+ }
111
+
112
+ // What each role may do. Specialists see calls and the inbox; editors also
113
+ // manage links and playbooks; owners also settings and the team.
114
+ const RANK = { specialist: 1, editor: 2, owner: 3 };
115
+ const NEED = [
116
+ [/^\w+ \/api\/admin\/(settings|openrouter|webhooks|transcripts|team|keys)\b/, "owner"],
117
+ [/^(POST|PUT|DELETE) \/api\/admin\/(links|playbooks|library)\b(?!\/check$)/, "editor"],
118
+ [/^DELETE \/api\/admin\/calls\//, "editor"],
119
+ ];
120
+
121
+ async function handle(req, res, url, user) {
122
+ const route = `${req.method} ${url.pathname}`;
123
+ const tail = (prefix) => (url.pathname.startsWith(prefix) ? decodeURIComponent(url.pathname.slice(prefix.length)) : null);
124
+ const need = NEED.find(([re]) => re.test(route))?.[1];
125
+ if (need && RANK[user.role] < RANK[need]) return send(res, 403, { error: `Only ${need === "owner" ? "owners" : "owners and editors"} can do that. You're signed in as ${user.role === "editor" ? "an" : "a"} ${user.role}.` });
126
+
127
+ // ── you ──
128
+ if (route === "GET /api/admin/me") return send(res, 200, { user, accounts: hasUsers() });
129
+ if (route === "POST /api/admin/me/password") {
130
+ const acct = getUser(user.id);
131
+ if (!acct) return send(res, 400, { error: "You're signed in with the shared password, which is changed in Settings." });
132
+ const body = await readBody(req);
133
+ if (!verifyLogin(acct.email, body.current)) return send(res, 400, { error: "Your current password isn't right." });
134
+ const r = setPassword(acct.id, body.password);
135
+ if (r.errors) return send(res, 400, { errors: r.errors });
136
+ return send(res, 200, { ok: true }, { "Set-Cookie": sessionCookieFor(getUser(acct.id), req) }); // stay signed in here, out everywhere else
137
+ }
138
+
139
+ // ── team (owners) ──
140
+ if (route === "GET /api/admin/team") return send(res, 200, { users: listUsers(), roles: ROLES, me: user.id, first: !hasUsers() });
141
+ if (route === "POST /api/admin/team") {
142
+ const body = await readBody(req);
143
+ const first = !hasUsers();
144
+ const password = body.password || suggestPassword();
145
+ const r = createUser({ email: body.email, name: body.name, role: first ? "owner" : body.role, password });
146
+ if (r.errors) return send(res, 400, { errors: r.errors });
147
+ refreshAuth(); // the first account turns sign-in on
148
+ // The first owner is whoever set it up: sign them in, or they'd be locked out.
149
+ return send(res, 201, { user: r.user, password: body.password ? undefined : password }, first ? { "Set-Cookie": sessionCookieFor(getUser(r.user.id), req) } : {});
150
+ }
151
+ const pw = url.pathname.match(/^\/api\/admin\/team\/([\w-]+)\/password$/);
152
+ if (pw && req.method === "POST") {
153
+ const password = (await readBody(req)).password || suggestPassword();
154
+ const r = setPassword(pw[1], password);
155
+ return r.errors ? send(res, 400, { errors: r.errors }) : send(res, 200, { user: r.user, password });
156
+ }
157
+ const memberId = tail("/api/admin/team/");
158
+ if (memberId != null && !pw) {
159
+ if (req.method === "PUT") { const r = updateUser(memberId, await readBody(req)); return r.errors ? send(res, 400, { errors: r.errors }) : send(res, 200, r); }
160
+ if (req.method === "DELETE") {
161
+ const r = deleteUser(memberId);
162
+ if (r.errors) return send(res, 400, { errors: r.errors });
163
+ refreshAuth();
164
+ return send(res, 200, r, memberId === user.id ? { "Set-Cookie": logoutCookie(req) } : {});
165
+ }
166
+ }
167
+
168
+ // ── API keys (owners) ──
169
+ if (route === "GET /api/admin/keys") return send(res, 200, { keys: listKeys(), roles: ROLES });
170
+ if (route === "POST /api/admin/keys") {
171
+ if (user.apiKey) return send(res, 403, { error: "API keys can't make more API keys. Use the dashboard." });
172
+ const b = await readBody(req);
173
+ const r = createKey({ name: b.name, role: b.role, createdBy: user.name });
174
+ return r.errors ? send(res, 400, { errors: r.errors }) : send(res, 201, r);
175
+ }
176
+ const keyId = tail("/api/admin/keys/");
177
+ if (keyId != null && req.method === "DELETE") return deleteKey(keyId) ? send(res, 200, { deleted: true }) : send(res, 404, { error: "unknown key" });
178
+
179
+ // ── inbox: pick up a customer who asked for a person ──
180
+ const pick = url.pathname.match(/^\/api\/admin\/calls\/([0-9a-f-]{36})\/pickup$/);
181
+ if (pick && req.method === "POST") {
182
+ const s = getSession(pick[1]);
183
+ const h = s?.ours.handoff;
184
+ if (!s || !h?.requested || !s.specialistKey) return send(res, 409, { error: "That customer isn't waiting any more." });
185
+ if (h.pickedUpBy && h.pickedUpBy.id !== user.id) return send(res, 409, { error: `${h.pickedUpBy.name} already picked this up.` });
186
+ h.pickedUpBy = { id: user.id, name: user.name, at: Date.now() };
187
+ emitEvent("handoff.picked_up", { sessionId: s.id, by: user.name, link: s.link ? { id: s.link.id, name: s.link.name } : null });
188
+ return send(res, 200, { joinPath: `/specialist?key=${encodeURIComponent(s.specialistKey)}` });
189
+ }
190
+
191
+ if (route === "GET /api/admin/overview") return send(res, 200, overview());
192
+ if (route === "GET /api/admin/analytics") {
193
+ const q = url.searchParams, days = [0, 7, 30, 90].includes(Number(q.get("days"))) ? Number(q.get("days")) : 30;
194
+ const all = recentCalls(5000);
195
+ const products = [...new Map(all.filter((c) => c.platform).map((c) => [c.platform.id, c.platform.name])).entries()].map(([id, name]) => ({ id, name }));
196
+ const links = [...new Map(all.filter((c) => c.link).map((c) => [c.link.id, c.link.name])).entries()].map(([id, name]) => ({ id, name }));
197
+ return send(res, 200, { ...analytics(all, { days, product: q.get("product") || "", link: q.get("link") || "" }), options: { products, links } });
198
+ }
199
+ if (route === "GET /api/admin/calls") return send(res, 200, { live: liveView(), recent: recentCalls(500), transcripts: transcriptsOn() });
200
+ if (route === "DELETE /api/admin/transcripts") { deleteAllTranscripts(); return send(res, 200, { deleted: true }); }
201
+ const callId = tail("/api/admin/calls/");
202
+ if (callId != null) {
203
+ const c = getCall(callId);
204
+ if (!c) return send(res, 404, { error: "That call isn't in the history (it may still be running, or was deleted)." });
205
+ if (req.method === "GET") return send(res, 200, { call: c, transcript: getTranscript(callId), transcripts: transcriptsOn() });
206
+ if (req.method === "DELETE") return send(res, 200, { deleted: deleteCall(callId) });
207
+ }
208
+ if (route === "GET /api/admin/settings") return send(res, 200, settings());
209
+ if (route === "PUT /api/admin/settings") {
210
+ const r = updateSettings((await readBody(req)).values);
211
+ if (r.errors) return send(res, 400, { errors: r.errors });
212
+ return send(res, 200, { ...r, ...settings() });
213
+ }
214
+ if (route === "POST /api/admin/openrouter/start") return send(res, 200, { url: startOpenRouter(ctx.origin(req)) });
215
+
216
+ // ── share links ──
217
+ if (route === "GET /api/admin/links") return send(res, 200, { links: listLinks().map(linkView), publicUrl: publicUrl(), localOnly: ctx.LOCAL_ONLY, password: AUTH_ENABLED });
218
+ if (route === "POST /api/admin/links") {
219
+ const r = validateLink(await readBody(req), { productExists });
220
+ if (r.errors) return send(res, 400, { errors: r.errors });
221
+ return send(res, 201, { link: linkView(createLink(r.value)) });
222
+ }
223
+ const linkId = tail("/api/admin/links/");
224
+ if (linkId != null) {
225
+ if (!getLink(linkId)) return send(res, 404, { error: "unknown link" });
226
+ if (req.method === "PUT") {
227
+ const r = validateLink(await readBody(req), { productExists });
228
+ if (r.errors) return send(res, 400, { errors: r.errors });
229
+ return send(res, 200, { link: linkView(updateLink(linkId, r.value)) });
230
+ }
231
+ if (req.method === "DELETE") return send(res, 200, { deleted: deleteLink(linkId) });
232
+ }
233
+
234
+ // ── playbooks ──
235
+ if (route === "GET /api/admin/playbooks") return send(res, 200, { ...playbooksAdminView(), template: template("my-product") });
236
+ // ── library and drafting ──
237
+ if (route === "GET /api/admin/library") {
238
+ try { return send(res, 200, await libraryView()); } catch (e) { return send(res, 502, { error: e.message }); }
239
+ }
240
+ const lib = url.pathname.match(/^\/api\/admin\/library\/([a-z0-9-]{1,40})\/install$/);
241
+ if (lib && req.method === "POST") {
242
+ try {
243
+ const r = await installFromLibrary(lib[1], { overwrite: !!(await readBody(req)).overwrite });
244
+ return r.errors ? send(res, r.status, { errors: r.errors }) : send(res, 200, r);
245
+ } catch (e) { return send(res, 502, { error: e.message }); }
246
+ }
247
+ if (route === "POST /api/admin/playbooks/draft") {
248
+ try { return send(res, 200, await draftPlaybook(await readBody(req))); }
249
+ catch (e) { return send(res, e.code === "daily_cap" ? 503 : e.status || 502, { error: e.code === "daily_cap" ? "Today's spending cap is reached, so drafting is paused until tomorrow." : e.message || "Drafting failed. Try again." }); }
250
+ }
251
+ if (route === "POST /api/admin/playbooks/check") {
252
+ const r = validatePlaybook((await readBody(req)).playbook);
253
+ return send(res, 200, { ok: !r.errors, errors: r.errors || [], warnings: r.warnings || [] });
254
+ }
255
+ const pbId = tail("/api/admin/playbooks/");
256
+ if (pbId != null && pbId !== "check") {
257
+ if (req.method === "PUT") {
258
+ const body = await readBody(req);
259
+ const previousId = body.previousId ? String(body.previousId) : undefined;
260
+ const r = savePlaybook(body.playbook, { previousId });
261
+ if (r.errors) return send(res, 400, { errors: r.errors });
262
+ if (previousId && previousId !== r.playbook.id) for (const l of listLinks()) if (l.product === previousId) updateLink(l.id, { product: r.playbook.id });
263
+ return send(res, 200, { playbook: r.playbook });
264
+ }
265
+ if (req.method === "DELETE") {
266
+ const using = listLinks().filter((l) => l.product === pbId);
267
+ if (using.length) return send(res, 409, { error: `Share links use this playbook: ${using.map((l) => `ā€œ${l.name}ā€`).join(", ")}. Point them at another product or delete them first.` });
268
+ return deletePlaybook(pbId) ? send(res, 200, { deleted: true }) : send(res, 404, { error: "Only your own playbooks can be deleted." });
269
+ }
270
+ }
271
+
272
+ // ── events ──
273
+ if (route === "POST /api/admin/webhooks/test") {
274
+ if (!WEBHOOK_INFO.endpoints && !WEBHOOK_INFO.slack) return send(res, 400, { error: "No webhooks are set up. Add WEBHOOK_URL or SLACK_WEBHOOK_URL to your settings and restart Ava." });
275
+ const results = await emitEvent("ava.test", { message: "Test event from the Ava dashboard" });
276
+ const ok = results.filter((r) => r.status === "fulfilled" && r.value === true).length;
277
+ return send(res, 200, { sent: results.length, delivered: ok });
278
+ }
279
+
280
+ return send(res, 404, { error: "unknown endpoint" });
281
+ }
282
+ handle.finishOpenRouter = finishOpenRouter;
283
+ return handle;
284
+ }
@@ -0,0 +1,73 @@
1
+ // Analytics for the dashboard, computed from the call history (finished calls).
2
+ // Answers what a team running onboarding wants to know: how many people finish,
3
+ // where they get stuck, how often they need a person, and what a finished
4
+ // onboarding costs.
5
+
6
+ const DAY = 86_400_000;
7
+ const median = (xs) => { if (!xs.length) return 0; const s = [...xs].sort((a, b) => a - b), m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
8
+ const rate = (n, d) => (d ? n / d : null);
9
+ const round = (n, k = 4) => (n == null ? null : Math.round(n * 10 ** k) / 10 ** k);
10
+
11
+ /** calls: finished-call records (newest first). Filters: days (0 = all), product id, link id ("test" = test calls). */
12
+ export function analytics(calls, { days = 30, product = "", link = "", now = Date.now() } = {}) {
13
+ const since = days ? now - days * DAY : 0;
14
+ const rows = calls.filter((c) => Date.parse(c.startedAt) >= since
15
+ && (!product || c.platform?.id === product)
16
+ && (!link || (link === "test" ? !c.link : c.link?.id === link)));
17
+ const onboarding = rows.filter((c) => c.workflow);
18
+ const completed = onboarding.filter((c) => c.workflow.completed);
19
+ const spend = rows.reduce((a, c) => a + (c.costUsd || 0), 0);
20
+
21
+ // calls per day, oldest first, with empty days filled in
22
+ const byDay = new Map();
23
+ for (const c of rows) {
24
+ const d = c.startedAt.slice(0, 10);
25
+ const b = byDay.get(d) || { date: d, calls: 0, completed: 0, handoffs: 0 };
26
+ b.calls++; if (c.workflow?.completed) b.completed++; if (c.handoffRequested) b.handoffs++;
27
+ byDay.set(d, b);
28
+ }
29
+ const span = days || Math.min(365, Math.max(1, Math.ceil((now - Math.min(now, ...rows.map((c) => Date.parse(c.startedAt)))) / DAY) + 1));
30
+ const daily = [];
31
+ for (let i = span - 1; i >= 0; i--) { const d = new Date(now - i * DAY).toISOString().slice(0, 10); daily.push(byDay.get(d) || { date: d, calls: 0, completed: 0, handoffs: 0 }); }
32
+
33
+ // the journey funnel per product: how many calls got each step done, and the biggest drop
34
+ const funnels = [];
35
+ const byProduct = new Map();
36
+ for (const c of onboarding) {
37
+ if (!c.platform || !c.steps) continue;
38
+ const f = byProduct.get(c.platform.id) || { product: c.platform, calls: 0, steps: new Map() };
39
+ f.calls++;
40
+ c.steps.forEach((s, i) => { const k = `${i}:${s.title}`; const e = f.steps.get(k) || { index: i, title: s.title, reached: 0 }; if (s.done) e.reached++; f.steps.set(k, e); });
41
+ byProduct.set(c.platform.id, f);
42
+ }
43
+ for (const f of byProduct.values()) {
44
+ const steps = [...f.steps.values()].sort((a, b) => a.index - b.index).map((s) => ({ ...s, pct: round(rate(s.reached, f.calls)) }));
45
+ let stuck = null, prev = 1;
46
+ steps.forEach((s, i) => { const drop = prev - (s.pct ?? 0); if (drop > 0.001 && (!stuck || drop > stuck.drop)) stuck = { index: i, title: s.title, drop: round(drop) }; prev = s.pct ?? 0; });
47
+ funnels.push({ product: f.product, calls: f.calls, steps, stuck });
48
+ }
49
+ funnels.sort((a, b) => b.calls - a.calls);
50
+
51
+ // by share link
52
+ const byLink = new Map();
53
+ for (const c of rows) {
54
+ const key = c.link?.id || "test";
55
+ const e = byLink.get(key) || { id: key, name: c.link?.name || "Test calls", calls: 0, onboarding: 0, completed: 0, handoffs: 0, spend: 0 };
56
+ e.calls++; if (c.workflow) e.onboarding++; if (c.workflow?.completed) e.completed++; if (c.handoffRequested) e.handoffs++; e.spend += c.costUsd || 0;
57
+ byLink.set(key, e);
58
+ }
59
+ const links = [...byLink.values()].map((e) => ({ ...e, spend: round(e.spend), completionRate: round(rate(e.completed, e.onboarding)), handoffRate: round(rate(e.handoffs, e.calls)), costPerCompletion: e.completed ? round(e.spend / e.completed) : null }))
60
+ .sort((a, b) => b.calls - a.calls);
61
+
62
+ return {
63
+ days, product: product || null, link: link || null,
64
+ totals: {
65
+ calls: rows.length, onboarding: onboarding.length, completed: completed.length,
66
+ completionRate: round(rate(completed.length, onboarding.length)),
67
+ handoffRate: round(rate(rows.filter((c) => c.handoffRequested).length, rows.length)),
68
+ medianMinutes: round(median(rows.map((c) => c.durationSec || 0)) / 60, 1),
69
+ spend: round(spend), costPerCompletion: completed.length ? round(spend / completed.length) : null,
70
+ },
71
+ daily, funnels, links,
72
+ };
73
+ }
package/lib/calls.mjs ADDED
@@ -0,0 +1,97 @@
1
+ // Call history for the dashboard.
2
+ //
3
+ // Every finished call: one line of numbers and step results (never what was said
4
+ // or shown), kept in memory (the latest 500) and appended to CALLS_FILE (default
5
+ // ~/.ava-engine/calls.jsonl) so a restart keeps it.
6
+ //
7
+ // Transcripts are separate and off by default (TRANSCRIPTS=on in Settings): what
8
+ // was said and the recap, one file per call in ~/.ava-engine/transcripts/ (mode
9
+ // 600), deleted after TRANSCRIPT_DAYS (default 30).
10
+
11
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+
15
+ const HOME = process.env.AVA_HOME || join(homedir(), ".ava-engine");
16
+ const FILE = process.env.CALLS_FILE || join(HOME, "calls.jsonl");
17
+ const TRANSCRIPTS = join(HOME, "transcripts");
18
+ const ID = /^[0-9a-f-]{36}$/;
19
+ const KEEP = 5000; // enough history for analytics
20
+ let history = load();
21
+
22
+ function load() {
23
+ try {
24
+ const lines = readFileSync(FILE, "utf8").trim().split("\n").slice(-KEEP);
25
+ return lines.map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean).reverse();
26
+ } catch { return []; }
27
+ }
28
+
29
+ /** Remember a finished call (newest first). */
30
+ export function recordCall(c) {
31
+ history.unshift(c);
32
+ if (history.length > KEEP) history.length = KEEP;
33
+ try {
34
+ mkdirSync(dirname(FILE), { recursive: true });
35
+ if (!existsSync(FILE)) writeFileSync(FILE, "", { mode: 0o600 });
36
+ appendFileSync(FILE, JSON.stringify(c) + "\n");
37
+ } catch (e) { console.warn(" [calls] couldn't save the call history:", e.message); }
38
+ }
39
+
40
+ export function recentCalls(limit = 100) { return history.slice(0, limit); }
41
+
42
+ /** Finished calls that started today (UTC). */
43
+ export function callsToday() {
44
+ const d = new Date().toISOString().slice(0, 10);
45
+ return history.filter((c) => String(c.startedAt || "").startsWith(d));
46
+ }
47
+
48
+ export function getCall(id) { return history.find((c) => c.id === id) || null; }
49
+
50
+ /** Forget a call: its history line and its transcript (e.g. a customer's deletion request). */
51
+ export function deleteCall(id) {
52
+ const n = history.length;
53
+ history = history.filter((c) => c.id !== id);
54
+ deleteTranscript(id);
55
+ if (history.length === n) return false;
56
+ try { writeFileSync(FILE, history.slice().reverse().map((c) => JSON.stringify(c) + "\n").join(""), { mode: 0o600 }); } catch {}
57
+ return true;
58
+ }
59
+
60
+ // ── transcripts (opt-in) ──
61
+ export const transcriptsOn = () => process.env.TRANSCRIPTS === "on";
62
+ const keepDays = () => Number(process.env.TRANSCRIPT_DAYS) || 30;
63
+ const tfile = (id) => join(TRANSCRIPTS, `${id}.json`);
64
+
65
+ /** Save what was said on a call. Only when transcripts are on. */
66
+ export function saveTranscript(id, data) {
67
+ if (!transcriptsOn() || !ID.test(id)) return false;
68
+ try {
69
+ mkdirSync(TRANSCRIPTS, { recursive: true, mode: 0o700 });
70
+ writeFileSync(tfile(id), JSON.stringify(data), { mode: 0o600 });
71
+ return true;
72
+ } catch (e) { console.warn(" [calls] couldn't save a transcript:", e.message); return false; }
73
+ }
74
+ export function getTranscript(id) {
75
+ if (!ID.test(id)) return null;
76
+ try { return JSON.parse(readFileSync(tfile(id), "utf8")); } catch { return null; }
77
+ }
78
+ export function deleteTranscript(id) { if (ID.test(id)) rmSync(tfile(id), { force: true }); }
79
+ /** Delete every saved transcript. */
80
+ export function deleteAllTranscripts() {
81
+ rmSync(TRANSCRIPTS, { recursive: true, force: true });
82
+ for (const c of history) c.transcript = false;
83
+ }
84
+
85
+ /** Delete transcripts older than TRANSCRIPT_DAYS. Runs at start and every hour. */
86
+ export function sweepTranscripts(now = Date.now()) {
87
+ let removed = 0;
88
+ try {
89
+ for (const f of readdirSync(TRANSCRIPTS)) {
90
+ const p = join(TRANSCRIPTS, f);
91
+ if (now - statSync(p).mtimeMs > keepDays() * 86_400_000) { rmSync(p, { force: true }); removed++; }
92
+ }
93
+ } catch { /* no transcripts yet */ }
94
+ return removed;
95
+ }
96
+ sweepTranscripts();
97
+ setInterval(sweepTranscripts, 3_600_000).unref();
@@ -26,6 +26,7 @@ export function readConfig(file = CONFIG_FILE) {
26
26
  if (!m) continue;
27
27
  let v = m[2];
28
28
  if (/^".*"$/.test(v)) v = v.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
29
+ else if (/^'.*'$/.test(v)) v = v.slice(1, -1); // written by the dashboard's Settings page
29
30
  out[m[1]] = v;
30
31
  }
31
32
  return out;
package/lib/cli/main.mjs CHANGED
@@ -23,6 +23,9 @@ const HELP = `
23
23
  npx ava-engine models [..] download models: ${Object.keys(MODEL_PACKS).join(", ")}, kokoro (voice)
24
24
  npx ava-engine playbooks list and check your product playbooks
25
25
  npx ava-engine playbooks new <id> write a starter playbook to fill in
26
+ npx ava-engine team list team accounts
27
+ npx ava-engine team add <email> [--name N] [--role owner|editor|specialist]
28
+ npx ava-engine team reset <email> new one-time password (if someone's locked out)
26
29
 
27
30
  Options
28
31
  --port <n> port to listen on (default ${DEFAULT_PORT})
@@ -50,6 +53,8 @@ function parseArgs(argv) {
50
53
  else if (a === "--no-open") out.flags.open = false;
51
54
  else if (a === "--port") out.flags.port = Number(argv[++i]);
52
55
  else if (a.startsWith("--port=")) out.flags.port = Number(a.slice(7));
56
+ else if (a === "--name") out.flags.name = String(argv[++i] || "");
57
+ else if (a === "--role") out.flags.role = String(argv[++i] || "");
53
58
  else if (a === "--models") out.flags.models = String(argv[++i] || "").split(",").filter(Boolean);
54
59
  else if (a.startsWith("--")) throw new Error(`unknown option ${a} (see --help)`);
55
60
  else out._.push(a);
@@ -63,6 +68,7 @@ async function start({ port, open }) {
63
68
  const cfg = readConfig() || {};
64
69
  for (const [k, v] of Object.entries(cfg)) if (process.env[k] === undefined) process.env[k] = v;
65
70
  process.env.AVA_MODELS_DIR ??= MODELS_DIR;
71
+ process.env.AVA_CONFIG_FILE ??= CONFIG_FILE; // shown on the dashboard's Settings page
66
72
  if (port) process.env.PORT = String(port);
67
73
  const p = Number(process.env.PORT) || DEFAULT_PORT;
68
74
  if (!(await portFree(p, process.env.HOST))) {
@@ -71,7 +77,7 @@ async function start({ port, open }) {
71
77
  }
72
78
  await import("../../server.mjs");
73
79
  if (open) {
74
- const url = `http://localhost:${p}/app`;
80
+ const url = `http://localhost:${p}/dashboard`;
75
81
  for (let i = 0; i < 50; i++) { // wait until the server answers, then open the app
76
82
  try { if ((await fetch(`http://127.0.0.1:${p}/api/health`)).ok) break; } catch {}
77
83
  await new Promise((r) => setTimeout(r, 200));
@@ -109,6 +115,38 @@ async function playbooks(args) {
109
115
  return problems.some((p) => p.errors.length) ? 1 : 0;
110
116
  }
111
117
 
118
+ /** `ava-engine team [add|reset]`: accounts from the terminal, e.g. to recover a lost owner password. */
119
+ async function teamCmd(args, flags) {
120
+ process.env.AVA_HOME ??= AVA_HOME;
121
+ const t = await import("../team.mjs");
122
+ const [sub, email] = args;
123
+ if (!sub) {
124
+ const users = t.listUsers();
125
+ console.log(`\n ΛVΛ Team (${t.USERS_FILE})\n`);
126
+ if (!users.length) console.log(" No accounts yet. Add an owner: npx ava-engine team add you@company.com --name \"Your Name\"");
127
+ for (const u of users) console.log(` ${u.role.padEnd(10)} ${u.name} <${u.email}>${u.lastLoginAt ? ` last signed in ${u.lastLoginAt.slice(0, 10)}` : ""}`);
128
+ console.log("");
129
+ return 0;
130
+ }
131
+ if (sub === "add") {
132
+ const password = t.suggestPassword();
133
+ const r = t.createUser({ email, name: flags.name || (email || "").split("@")[0], role: flags.role || (t.hasUsers() ? "specialist" : "owner"), password });
134
+ if (r.errors) { console.error(`āœ— ${r.errors.join(" ")}`); return 1; }
135
+ console.log(`\n āœ“ Added ${r.user.name} <${r.user.email}> as ${r.user.role}.\n One-time password: ${password}\n Share it privately. They can change it under "Your account" after signing in at /login.\n`);
136
+ return 0;
137
+ }
138
+ if (sub === "reset") {
139
+ const u = t.listUsers().find((x) => x.email === String(email || "").toLowerCase());
140
+ if (!u) { console.error(`āœ— No account for ${email || "(no email given)"}. List them with: npx ava-engine team`); return 1; }
141
+ const password = t.suggestPassword();
142
+ t.setPassword(u.id, password);
143
+ console.log(`\n āœ“ New one-time password for ${u.name}: ${password}\n They're signed out everywhere until they use it.\n`);
144
+ return 0;
145
+ }
146
+ console.error(`āœ— unknown team command "${sub}" (try: team, team add <email>, team reset <email>)`);
147
+ return 2;
148
+ }
149
+
112
150
  async function doctor() {
113
151
  const cfg = readConfig();
114
152
  const rows = [];
@@ -151,6 +189,7 @@ export async function main(argv) {
151
189
  case "help": console.log(HELP); return 0;
152
190
  case "doctor": return doctor();
153
191
  case "playbooks": return playbooks(args._.slice(1));
192
+ case "team": return teamCmd(args._.slice(1), flags);
154
193
  case "start": await start({ port: flags.port, open: flags.open === true }); return null; // keeps running
155
194
  case "models": {
156
195
  const { downloadPacks, downloadKokoro } = await import("./setup.mjs");
@@ -184,11 +223,12 @@ export async function main(argv) {
184
223
  const p = await import("@clack/prompts");
185
224
  console.log("");
186
225
  p.intro(" Ī›VĪ› Ava Engine setup ");
226
+ p.log.message("Five short steps: AI, voice, speech recognition, who can reach Ava, launch.\nAbout two minutes, and you can change anything later.");
187
227
  where = await p.select({
188
228
  message: "Where would you like to set up Ava?",
189
229
  options: [
190
- { value: "web", label: "In my browser", hint: "recommended; a step-by-step page" },
191
- { value: "terminal", label: "Here in the terminal" },
230
+ { value: "web", label: "In my browser", hint: "recommended; a guided page with previews" },
231
+ { value: "terminal", label: "Here in the terminal", hint: "quick questions, works over SSH" },
192
232
  ],
193
233
  });
194
234
  if (p.isCancel(where)) { p.cancel("Setup cancelled. Nothing was changed."); return 0; }
@@ -12,6 +12,7 @@ import { readFile } from "node:fs/promises";
12
12
  import { join, dirname } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { randomBytes, timingSafeEqual } from "node:crypto";
15
+ import { homedir } from "node:os";
15
16
  import { CONFIG_FILE, MODELS_DIR, DEFAULT_PORT, readConfig, writeConfig, checkOpenRouterKey, checkElevenLabsKey, portFree, suggestPassword } from "./config.mjs";
16
17
  import { buildConfig, validateAnswers, VOICES } from "./apply.mjs";
17
18
  import { MODEL_PACKS, installedPacks, fetchPack } from "../fetch-models.mjs";
@@ -83,7 +84,7 @@ export async function runWebSetup({ preferredPort, formPort, onReady } = {}) {
83
84
  function state() {
84
85
  const c = readConfig() || prev;
85
86
  return {
86
- configFile: CONFIG_FILE,
87
+ configFile: CONFIG_FILE.replace(homedir(), "~"),
87
88
  current: {
88
89
  openrouterKey: mask(c.OPENROUTER_API_KEY),
89
90
  voice: VOICES[c.TTS_DEFAULT] ? c.TTS_DEFAULT : "kokoro",
@@ -195,7 +196,7 @@ export async function runWebSetup({ preferredPort, formPort, onReady } = {}) {
195
196
  const p = Number(cfg.PORT);
196
197
  if (p !== listenPort && !(await portFree(p, cfg.HOST))) return send(409, { error: `Port ${p} is in use by another program. Pick another port.` });
197
198
  writeConfig(cfg);
198
- return send(200, { ok: true, port: p, url: `http://localhost:${p}/app`, configFile: CONFIG_FILE });
199
+ return send(200, { ok: true, port: p, url: `http://localhost:${p}/dashboard`, configFile: CONFIG_FILE });
199
200
  }
200
201
  if (route === "POST /api/setup/finish") {
201
202
  const cfg = readConfig();
package/lib/cli/setup.mjs CHANGED
@@ -214,7 +214,7 @@ export async function runSetup() {
214
214
  `Voice ${VOICES[cfg.TTS_DEFAULT]?.label || "Kokoro"}`,
215
215
  `Speech models ${installedPacks(MODELS_DIR).map((x) => MODEL_PACKS[x].label).join(", ") || "none (browser speech recognition)"}`,
216
216
  `Access ${cfg.HOST === "127.0.0.1" ? "this computer only" : `password protected${cfg.ACCESS_PASSWORD ? ` (${cfg.ACCESS_PASSWORD})` : ""}`}`,
217
- `Address http://localhost:${cfg.PORT}/app`,
217
+ `Dashboard http://localhost:${cfg.PORT}/dashboard`,
218
218
  "",
219
219
  `Saved to ${CONFIG_FILE}`,
220
220
  ].join("\n"),