ava-engine 0.2.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 CHANGED
@@ -16,11 +16,22 @@ PORT=5190
16
16
  # ACCESS_PASSWORD=
17
17
  # TRUST_PROXY=1 # behind Fly/Cloudflare/nginx: rate-limit by the real client IP
18
18
  # SESSION_BUDGET_USD=2 # model spend cap per call
19
+ # DAILY_BUDGET_USD=20 # model spend cap per UTC day across all calls (unset = none)
19
20
  # MAX_SESSIONS=50 # concurrent calls (idle ones expire after 30 min)
20
21
  # MAX_ASR_CONNECTIONS=40 # concurrent speech-to-text streams
21
22
  # RATE_SESSIONS_PER_MIN=10 # new calls per client IP per minute
22
23
  # RATE_API_PER_MIN=240 # other API requests per client IP per minute
23
24
 
25
+ # ── Your products ──
26
+ # PLAYBOOKS_DIR=/srv/ava/playbooks # one JSON file per product (default ~/.ava-engine/playbooks): npx ava-engine playbooks new <id>
27
+ # BUILTIN_PLAYBOOKS=off # hide the built-in sample products from the lobby
28
+
29
+ # ── Events & handoff alerts ──
30
+ # WEBHOOK_URL=https://your-app.example/ava-events # receives every call event as JSON (comma-separate several)
31
+ # WEBHOOK_SECRET= # signs deliveries: X-Ava-Signature: sha256=HMAC(secret, "<timestamp>.<body>")
32
+ # SLACK_WEBHOOK_URL= # Slack incoming webhook: posts when a customer asks for a person
33
+ # PUBLIC_URL=https://ava.your-site.example # base for the specialist links in those alerts
34
+
24
35
  # ── Models (OpenRouter slugs) — one per role ──
25
36
  # MODEL_TALKER=meta-llama/llama-3.3-70b-instruct # the spoken reply (latency-critical)
26
37
  # MODEL_PERCEPTION=google/gemini-2.5-flash # reads the shared screen
package/README.md CHANGED
@@ -149,12 +149,14 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
149
149
  **For the customer**
150
150
  - **Talks, naturally.** Live voice with streamed replies, interruptible at any time (barge-in), in text or voice.
151
151
  - **Sees their screen.** Reads the shared tab — the page, what's selected, the numbers on it — and grounds every answer in it. Never invents buttons; if it isn't sure, it takes another look.
152
- - **Helps with anything on the page.** A *Flexible agent* for whatever the customer is doing, and an *Onboarding agent* that walks a journey step by step on a chosen product (Todoist, Supabase, Stripe, AWS Console, Salesforce, Datadog, Vercel).
152
+ - **Helps with anything on the page.** A *Flexible agent* for whatever the customer is doing, and an *Onboarding agent* that walks a journey step by step on your product, described in a JSON [playbook](docs/setup/customize.md) (samples included for Todoist, Supabase, Stripe, AWS Console, Salesforce, Datadog and Vercel).
153
153
  - **Speaks up.** Reacts when the screen changes, and flags risky or costly moves before they happen (a $9,900 seat count, a destructive click).
154
154
  - **Knows when it's done.** Confirms each step actually worked, and goes quiet once the task is complete.
155
155
  - **A person, one tap away.** Hands off to a human specialist with the goal, the screen and the progress already shared — the specialist joins the same call over WebRTC.
156
156
 
157
157
  **For the team running it**
158
+ - **Events** — webhooks for call started, step completed, handoff requested and call ended, plus a Slack alert with a join link when a customer asks for a person.
159
+ - **Spend caps** — per call and per day across all calls.
158
160
  - **Session recap** — an AI summary, highlights and stats, plus a replay of how the agents worked, with a timestamped timeline.
159
161
  - **Console** — end-to-end latency per turn, which agents ran and how long each took, the compiled context the talker saw, and live spend.
160
162
  - **Per-platform theming** — onboarding calls take on the product's brand colour.
package/lib/cli/main.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // The `ava-engine` command.
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join, dirname } from "node:path";
4
+ import { homedir } from "node:os";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { createRequire } from "node:module";
6
7
  import { AVA_HOME, CONFIG_FILE, MODELS_DIR, DEFAULT_PORT, readConfig, checkOpenRouterKey, portFree, openBrowser } from "./config.mjs";
@@ -20,6 +21,8 @@ const HELP = `
20
21
  npx ava-engine start start the server
21
22
  npx ava-engine doctor check your setup and tell you what to fix
22
23
  npx ava-engine models [..] download models: ${Object.keys(MODEL_PACKS).join(", ")}, kokoro (voice)
24
+ npx ava-engine playbooks list and check your product playbooks
25
+ npx ava-engine playbooks new <id> write a starter playbook to fill in
23
26
 
24
27
  Options
25
28
  --port <n> port to listen on (default ${DEFAULT_PORT})
@@ -77,6 +80,35 @@ async function start({ port, open }) {
77
80
  }
78
81
  }
79
82
 
83
+ /** `ava-engine playbooks [new <id>]`: your products as JSON files. */
84
+ async function playbooks(args) {
85
+ const { loadPlaybooks, validatePlaybook, template } = await import("../playbooks.mjs");
86
+ const cfg = readConfig() || {};
87
+ const dir = (process.env.PLAYBOOKS_DIR || cfg.PLAYBOOKS_DIR || join(AVA_HOME, "playbooks")).replace(/^~(?=$|[\\/])/, homedir());
88
+ if (args[0] === "new") {
89
+ const id = args[1];
90
+ if (!id || validatePlaybook({ ...template(id), id }).errors) { console.error("✗ usage: ava-engine playbooks new <id> (lowercase letters, digits and dashes, e.g. acme)"); return 2; }
91
+ const file = join(dir, `${id}.json`);
92
+ if (existsSync(file)) { console.error(`✗ ${file} already exists`); return 1; }
93
+ mkdirSync(dir, { recursive: true });
94
+ writeFileSync(file, JSON.stringify(template(id), null, 2) + "\n");
95
+ console.log(`\n ✓ Wrote ${file}\n\n Fill it in for your product, check it with: npx ava-engine playbooks\n then restart Ava. Field guide: https://github.com/Mr-vero/ava-engine/blob/main/docs/setup/customize.md\n`);
96
+ return 0;
97
+ }
98
+ if (args.length) { console.error(`✗ unknown playbooks command "${args[0]}" (try: ava-engine playbooks new <id>)`); return 2; }
99
+ const { playbooks: ok, problems } = loadPlaybooks(dir);
100
+ console.log(`\n ΛVΛ Playbooks in ${dir}\n`);
101
+ if (!ok.length && !problems.length) console.log(" None yet. Create one with: npx ava-engine playbooks new <id>");
102
+ for (const p of ok) console.log(` ✓ ${p.name} (${p.id}): ${p.journey.length} steps, ${p.knowledge.length} knowledge entries [${p.source}]`);
103
+ for (const p of problems) {
104
+ for (const e of p.errors) console.log(` ✗ ${p.file}: ${e}`);
105
+ for (const w of p.warnings) console.log(` ! ${p.file}: ${w}`);
106
+ }
107
+ const hidden = /^(off|0|false|no)$/i.test(process.env.BUILTIN_PLAYBOOKS || cfg.BUILTIN_PLAYBOOKS || "");
108
+ console.log(`\n Built-in sample products are ${hidden ? "hidden (BUILTIN_PLAYBOOKS=off)" : "shown too. Hide them with BUILTIN_PLAYBOOKS=off in " + CONFIG_FILE}.\n`);
109
+ return problems.some((p) => p.errors.length) ? 1 : 0;
110
+ }
111
+
80
112
  async function doctor() {
81
113
  const cfg = readConfig();
82
114
  const rows = [];
@@ -118,6 +150,7 @@ export async function main(argv) {
118
150
  switch (cmd) {
119
151
  case "help": console.log(HELP); return 0;
120
152
  case "doctor": return doctor();
153
+ case "playbooks": return playbooks(args._.slice(1));
121
154
  case "start": await start({ port: flags.port, open: flags.open === true }); return null; // keeps running
122
155
  case "models": {
123
156
  const { downloadPacks, downloadKokoro } = await import("./setup.mjs");
@@ -0,0 +1,87 @@
1
+ // "Connect OpenRouter": get an API key by signing in, instead of copying one.
2
+ //
3
+ // OpenRouter's OAuth PKCE flow (https://openrouter.ai/docs/use-cases/oauth-pkce):
4
+ // 1. open openrouter.ai/auth with a localhost callback and a S256 code challenge,
5
+ // 2. the person approves; OpenRouter redirects to the callback with ?code=…,
6
+ // 3. we trade the code (plus the verifier only we know) for a key.
7
+ // The key is billed to their own OpenRouter credit. Codes expire after 10 minutes.
8
+
9
+ import http from "node:http";
10
+ import { createHash, randomBytes } from "node:crypto";
11
+
12
+ // Overridable so tests can stand in for openrouter.ai; never set this in real use.
13
+ const BASE = () => (process.env.AVA_OPENROUTER_BASE || "https://openrouter.ai").replace(/\/+$/, "");
14
+ export const FLOW_TIMEOUT_MS = 10 * 60_000;
15
+
16
+ /** A fresh PKCE pair and CSRF state. */
17
+ export function pkce() {
18
+ const verifier = randomBytes(32).toString("base64url");
19
+ return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url"), state: randomBytes(16).toString("base64url") };
20
+ }
21
+
22
+ export function authUrl({ callbackUrl, challenge, state }) {
23
+ const q = new URLSearchParams({ callback_url: callbackUrl, code_challenge: challenge, code_challenge_method: "S256", state, key_label: "Ava Engine" });
24
+ return `${BASE()}/auth?${q}`;
25
+ }
26
+
27
+ /** Trade the callback's code for an API key. Throws an Error with a message fit to show. */
28
+ export async function exchangeCode({ code, verifier }) {
29
+ let r;
30
+ try {
31
+ r = await fetch(`${BASE()}/api/v1/auth/keys`, {
32
+ method: "POST", headers: { "Content-Type": "application/json" },
33
+ body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: "S256" }),
34
+ signal: AbortSignal.timeout(15_000),
35
+ });
36
+ } catch { throw new Error("Couldn't reach OpenRouter. Check your connection and try again."); }
37
+ // The docs say 403 for a bad or expired code; the live API answers 400.
38
+ if (r.status === 400 || r.status === 403) throw new Error("OpenRouter didn't accept the sign-in (it may have expired). Try connecting again.");
39
+ if (!r.ok) throw new Error(`OpenRouter answered ${r.status}. Try again, or paste a key instead.`);
40
+ const j = await r.json().catch(() => ({}));
41
+ if (typeof j.key !== "string" || j.key.length < 20) throw new Error("OpenRouter didn't return a key. Try again, or paste a key instead.");
42
+ return j.key;
43
+ }
44
+
45
+ /** The page the browser lands on after approving. Fixed text only: nothing from the request is echoed. */
46
+ export function callbackPage(ok, { cssHref } = {}) {
47
+ const [title, text] = ok
48
+ ? ["OpenRouter is connected", "You can close this tab and go back to setup."]
49
+ : ["Not connected", "OpenRouter didn't connect. Close this tab and try again, or paste a key instead."];
50
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title} · Ava setup</title>${cssHref ? `<link rel="stylesheet" href="${cssHref}">` : ""}</head>`
51
+ + `<body><main class="panel launching"><h1>${ok ? "✓ " : ""}${title}</h1><p class="lead">${text}</p></main></body></html>`;
52
+ }
53
+
54
+ /**
55
+ * Terminal flow: listen on 127.0.0.1, open the browser, wait for the callback.
56
+ * Resolves the key, or rejects (timeout, refused, bad state).
57
+ * onUrl(url) is told the sign-in URL (open it and print it).
58
+ */
59
+ export function connectInBrowser({ onUrl, timeoutMs = FLOW_TIMEOUT_MS } = {}) {
60
+ const { verifier, challenge, state } = pkce();
61
+ return new Promise((resolve, reject) => {
62
+ let done = false;
63
+ const finish = (err, key) => {
64
+ if (done) return; done = true;
65
+ clearTimeout(timer);
66
+ setTimeout(() => server.close(), 200).unref(); // let the callback page finish sending
67
+ server.closeAllConnections?.();
68
+ if (err) reject(err); else resolve(key);
69
+ };
70
+ const server = http.createServer(async (req, res) => {
71
+ const url = new URL(req.url, "http://localhost");
72
+ if (url.pathname !== "/callback") { res.writeHead(404); return res.end(); }
73
+ const page = (ok) => { res.writeHead(ok ? 200 : 400, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "Content-Security-Policy": "default-src 'none'" }); res.end(callbackPage(ok)); };
74
+ const code = url.searchParams.get("code");
75
+ if (!code) { page(false); return finish(new Error("The sign-in was cancelled.")); }
76
+ if (url.searchParams.get("state") !== state) { page(false); return; } // not ours: keep waiting for the real one
77
+ try { const key = await exchangeCode({ code, verifier }); page(true); finish(null, key); }
78
+ catch (e) { page(false); finish(e); }
79
+ });
80
+ const timer = setTimeout(() => finish(new Error("Timed out waiting for the OpenRouter sign-in.")), timeoutMs);
81
+ timer.unref?.();
82
+ server.on("error", (e) => finish(e));
83
+ server.listen(0, "127.0.0.1", () => {
84
+ onUrl?.(authUrl({ callbackUrl: `http://localhost:${server.address().port}/callback`, challenge, state }));
85
+ });
86
+ });
87
+ }
@@ -16,6 +16,7 @@ import { CONFIG_FILE, MODELS_DIR, DEFAULT_PORT, readConfig, writeConfig, checkOp
16
16
  import { buildConfig, validateAnswers, VOICES } from "./apply.mjs";
17
17
  import { MODEL_PACKS, installedPacks, fetchPack } from "../fetch-models.mjs";
18
18
  import { loadKokoro, kokoroInstalled, KOKORO_SIZE } from "../kokoro-model.mjs";
19
+ import { pkce, authUrl, exchangeCode, callbackPage, FLOW_TIMEOUT_MS } from "./openrouter-oauth.mjs";
19
20
 
20
21
  const PUBLIC = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "public");
21
22
  const STATIC = new Map([
@@ -51,6 +52,8 @@ export async function runWebSetup({ preferredPort, formPort, onReady } = {}) {
51
52
  const prev = readConfig() || {};
52
53
  const token = randomBytes(24).toString("base64url");
53
54
  const jobs = {}; // id → { label, state: "running" | "done" | "error", pct, error }
55
+ // "Connect OpenRouter": one sign-in at a time. The key stays here; the page only sees it masked.
56
+ let oauth = null; // { verifier, state, startedAt, status: "waiting" | "connected" | "error", key, error }
54
57
 
55
58
  const okToken = (req) => {
56
59
  const t = Buffer.from(String(req.headers["x-setup-token"] || ""));
@@ -120,6 +123,20 @@ export async function runWebSetup({ preferredPort, formPort, onReady } = {}) {
120
123
  try { return send(200, await readFile(join(PUBLIC, file)), type); } catch { return send(404, "not found", "text/plain"); }
121
124
  }
122
125
  if (req.method === "GET" && url.pathname === "/") { res.writeHead(302, { Location: "/setup" }); return res.end(); }
126
+ // OpenRouter redirects the browser here after sign-in. It can't carry the page's
127
+ // token, so the one-time state from /openrouter/start proves it's ours.
128
+ if (req.method === "GET" && url.pathname === "/openrouter/callback") {
129
+ const page = (ok) => send(ok ? 200 : 400, callbackPage(ok, { cssHref: "/setup.css" }), "text/html; charset=utf-8");
130
+ const f = oauth;
131
+ if (!f || f.status !== "waiting" || Date.now() - f.startedAt > FLOW_TIMEOUT_MS) return page(false);
132
+ const code = url.searchParams.get("code");
133
+ if (!code) { f.status = "error"; f.error = "The sign-in was cancelled."; return page(false); }
134
+ const st = Buffer.from(String(url.searchParams.get("state") || "")), want = Buffer.from(f.state);
135
+ if (!(st.length === want.length && timingSafeEqual(st, want))) return page(false);
136
+ f.status = "exchanging";
137
+ try { f.key = await exchangeCode({ code, verifier: f.verifier }); f.status = "connected"; return page(true); }
138
+ catch (e) { f.status = "error"; f.error = e.message; return page(false); }
139
+ }
123
140
  if (!url.pathname.startsWith("/api/setup/")) return send(404, "not found", "text/plain");
124
141
  if (!okToken(req)) return send(401, { error: "This setup link has expired. Run `npx ava-engine setup --web` for a new one." });
125
142
 
@@ -143,6 +160,15 @@ export async function runWebSetup({ preferredPort, formPort, onReady } = {}) {
143
160
  if (r.ok && r.label && /^sk-or/i.test(r.label)) delete r.label; // unnamed keys report a masked key as their label
144
161
  return send(200, r);
145
162
  }
163
+ if (route === "POST /api/setup/openrouter/start") {
164
+ const { verifier, challenge, state: st } = pkce();
165
+ oauth = { verifier, state: st, startedAt: Date.now(), status: "waiting" };
166
+ return send(200, { url: authUrl({ callbackUrl: `http://localhost:${listenPort}/openrouter/callback`, challenge, state: st }) });
167
+ }
168
+ if (route === "GET /api/setup/openrouter/status") {
169
+ if (oauth?.status === "waiting" && Date.now() - oauth.startedAt > FLOW_TIMEOUT_MS) { oauth.status = "error"; oauth.error = "The sign-in timed out. Try again."; }
170
+ return send(200, oauth ? { status: oauth.status, key: oauth.key ? mask(oauth.key) : undefined, error: oauth.error } : { status: "none" });
171
+ }
146
172
  if (route === "POST /api/setup/download") {
147
173
  const id = String(body.id || "");
148
174
  if (id !== "kokoro" && !MODEL_PACKS[id]) return send(400, { error: "unknown download" });
@@ -151,9 +177,10 @@ export async function runWebSetup({ preferredPort, formPort, onReady } = {}) {
151
177
  }
152
178
  if (route === "POST /api/setup/save") {
153
179
  const a = body.answers || {};
154
- // "keep" means: leave the saved key as it is.
180
+ // "keep" means: leave the saved key as it is; "connected": the key from Connect OpenRouter.
181
+ if (a.mode !== "mock" && a.openrouterKey === "connected" && oauth?.status !== "connected") return send(400, { error: "Connect OpenRouter again: the sign-in didn't finish." });
155
182
  const answers = {
156
- openrouterKey: a.mode === "mock" ? null : a.openrouterKey === "keep" ? undefined : String(a.openrouterKey || "") || null,
183
+ openrouterKey: a.mode === "mock" ? null : a.openrouterKey === "keep" ? undefined : a.openrouterKey === "connected" ? oauth.key : String(a.openrouterKey || "") || null,
157
184
  voice: a.voice,
158
185
  elevenKey: a.elevenKey === "keep" ? undefined : a.elevenKey,
159
186
  elevenVoice: a.elevenVoice,
package/lib/cli/setup.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  // `ava-engine setup` in the terminal: the guided wizard (and its non-interactive twin).
2
2
  // The browser version lives in setup-server.mjs; both save through apply.mjs.
3
3
  import * as p from "@clack/prompts";
4
- import { CONFIG_FILE, MODELS_DIR, DEFAULT_PORT, readConfig, writeConfig, checkOpenRouterKey, checkElevenLabsKey, portFree, suggestPassword } from "./config.mjs";
4
+ import { CONFIG_FILE, MODELS_DIR, DEFAULT_PORT, readConfig, writeConfig, checkOpenRouterKey, checkElevenLabsKey, portFree, suggestPassword, openBrowser } from "./config.mjs";
5
+ import { connectInBrowser } from "./openrouter-oauth.mjs";
5
6
  import { buildConfig, VOICES } from "./apply.mjs";
6
7
  import { MODEL_PACKS, installedPacks, fetchPack } from "../fetch-models.mjs";
7
8
  import { loadKokoro, kokoroInstalled, KOKORO_SIZE } from "../kokoro-model.mjs";
@@ -83,8 +84,31 @@ export async function runSetup() {
83
84
  } else {
84
85
  let key = prev.OPENROUTER_API_KEY;
85
86
  if (key && !bail(await p.confirm({ message: `Keep your current OpenRouter key (${mask(key)})?`, initialValue: true }))) key = undefined;
87
+ if (!key) p.log.message("Ava's AI runs through OpenRouter: one key for every model.");
86
88
  while (!key) {
87
- p.log.message("Ava's AI runs through OpenRouter: one key for every model.\nGet one at https://openrouter.ai/keys (set a credit limit on it).");
89
+ const how = bail(await p.select({
90
+ message: "How do you want to add your OpenRouter key?",
91
+ options: [
92
+ { value: "connect", label: "Connect my OpenRouter account", hint: "recommended; sign in and approve in your browser" },
93
+ { value: "paste", label: "Paste a key", hint: "from https://openrouter.ai/keys" },
94
+ { value: "mock", label: "Skip for now", hint: "MOCK mode" },
95
+ ],
96
+ }));
97
+ if (how === "mock") break;
98
+ if (how === "connect") {
99
+ const s = p.spinner();
100
+ try {
101
+ key = await connectInBrowser({
102
+ onUrl: (url) => {
103
+ p.log.message(`Opening OpenRouter in your browser. If it doesn't open, go to:\n${url}`);
104
+ openBrowser(url);
105
+ s.start("Waiting for you to approve in the browser (Ctrl+C to stop)");
106
+ },
107
+ });
108
+ s.stop("Connected to OpenRouter. Tip: set a credit limit on the new key at https://openrouter.ai/keys");
109
+ } catch (e) { s.error(e.message); }
110
+ continue;
111
+ }
88
112
  const entered = bail(await p.password({ message: "Paste your OpenRouter API key", validate: (v) => (!v || v.trim().length < 20 ? "That doesn't look like a full key" : undefined) })).trim();
89
113
  const s = p.spinner(); s.start("Checking the key with OpenRouter");
90
114
  const r = await checkOpenRouterKey(entered);
package/lib/mock.mjs ADDED
@@ -0,0 +1,78 @@
1
+ // Demo mode (no OPENROUTER_API_KEY): what each agent "says" without a model.
2
+ //
3
+ // Rules: always say it's a demo and how to switch real answers on; never claim to
4
+ // see a screen (there's no vision without a model); and use the context we really
5
+ // have: the chosen product, the current onboarding step, what the user asked.
6
+
7
+ // Spoken aloud too, so no command names here; the lobby shows the exact command.
8
+ const KEY_HINT = "add an OpenRouter key in setup for real answers";
9
+
10
+ function quote(text, max = 70) {
11
+ const t = String(text || "").replace(/\s+/g, " ").trim();
12
+ return t.length > max ? t.slice(0, max - 1).trimEnd() + "…" : t;
13
+ }
14
+
15
+ /** The onboarding step the user is on, with its how-to line from the playbook. */
16
+ function currentStep(o) {
17
+ if (o.mode !== "onboarding" || !o.platform || !o.workflow) return null;
18
+ const i = o.workflow.current ?? 0;
19
+ return { title: o.workflow.steps[i]?.title, how: o.platform.path?.[i] };
20
+ }
21
+
22
+ const sharing = (o) => !!(o.screenState && !o.screenState.stale);
23
+
24
+ /** Greeting. */
25
+ export function greet(o) {
26
+ if (o.mode === "onboarding" && o.platform) {
27
+ return `Hi, I'm Ava, your ${o.platform.name} guide. Heads up: this is demo mode, so my replies are canned until you ${KEY_HINT}. Voice, screen sharing and the step tracker all work, so share your ${o.platform.name} tab and try it.`;
28
+ }
29
+ return `Hi, I'm Ava. Heads up: this is demo mode, so my replies are canned until you ${KEY_HINT}. Voice and screen sharing work, so share a tab or ask me anything to see how a call feels.`;
30
+ }
31
+
32
+ /** The talker's reply to a user turn. `kind` is the router's mode (help/support/wrap…). */
33
+ export function reply(o, userText, kind = "") {
34
+ if (/WRAP/i.test(kind)) return "Demo reply: no problem, we can pick this up whenever you like.";
35
+ if (/SUPPORT/i.test(kind)) return `Demo reply: sorry this is being a pain. I'm in demo mode right now, so ${KEY_HINT}, or ask for a person with “Talk to a person”.`;
36
+ const asked = quote(userText);
37
+ const step = currentStep(o);
38
+ if (step?.title) {
39
+ return `Demo reply: on ${o.platform.name}, the next step is “${step.title}”${step.how ? `: ${step.how}` : ""}. In demo mode I can't read your screen, so ${KEY_HINT} and I'll guide you based on what's actually there.`;
40
+ }
41
+ if (sharing(o)) {
42
+ return `Demo reply: you're sharing a tab, but in demo mode I can't read it. ${KEY_HINT[0].toUpperCase() + KEY_HINT.slice(1)}, and I'll answer “${asked}” based on what's on your screen.`;
43
+ }
44
+ return asked
45
+ ? `Demo reply: you asked “${asked}”. I'm in demo mode, so ${KEY_HINT}. Sharing a tab works now too.`
46
+ : `Demo reply: I'm in demo mode, so ${KEY_HINT}.`;
47
+ }
48
+
49
+ /** Perception: honest about not reading the screen. */
50
+ export function vision() {
51
+ return JSON.stringify({
52
+ app: "Shared tab", page: "not read in demo mode", elements: [], content: [],
53
+ state: "Demo mode can't read screens without an OpenRouter key.",
54
+ look: "", activity: "no real change",
55
+ });
56
+ }
57
+
58
+ export function thinking() {
59
+ return JSON.stringify({ resolved: false, stuck: false, blocker: "", thought: "Demo mode: reasoning needs an OpenRouter key.", nextTactic: "" });
60
+ }
61
+
62
+ export function planner(o) {
63
+ if (o.mode === "onboarding" && o.platform) {
64
+ return JSON.stringify({ goal: `Get set up on ${o.platform.name} and into the dashboard`, understanding: "demo mode: following the playbook's steps", plan: (o.platform.journey || []).slice(0, 3), guidance: "demo mode" });
65
+ }
66
+ return JSON.stringify({ goal: "Try Ava in demo mode", understanding: "exploring the demo", plan: ["Share a tab", "Ask a question", "Add an OpenRouter key for real answers"], guidance: "demo mode" });
67
+ }
68
+
69
+ /** Intention guess while browsing silently: low confidence, so it's ignored. */
70
+ export function guess() {
71
+ return JSON.stringify({ goal: "explore Ava in demo mode", understanding: "demo mode", confidence: 0.2 });
72
+ }
73
+
74
+ export function nudge(o) {
75
+ return o.mode === "onboarding" && o.platform
76
+ ? `Demo mode: share your ${o.platform.name} tab or ask me about a step to see how I respond.`
77
+ : "Demo mode: share a tab or ask me something to see how I respond.";
78
+ }
@@ -1,5 +1,6 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { estimateCost } from "./models.mjs";
3
+ import { dailyCapReached, recordSpend, DailyCapError } from "./spend.mjs";
3
4
 
4
5
  // The HTTP request a model call belongs to. When that client disconnects mid-turn,
5
6
  // its in-flight calls are aborted instead of spending tokens nobody will read.
@@ -63,6 +64,7 @@ export async function chat({ model, role, messages, maxTokens = 350, temperature
63
64
  };
64
65
  }
65
66
 
67
+ if (dailyCapReached()) throw new DailyCapError();
66
68
  const body = {
67
69
  model,
68
70
  messages,
@@ -96,6 +98,7 @@ export async function chat({ model, role, messages, maxTokens = 350, temperature
96
98
  const promptTokens = u.prompt_tokens ?? 0;
97
99
  const completionTokens = u.completion_tokens ?? 0;
98
100
  const cost = typeof u.cost === "number" ? u.cost : estimateCost(model, promptTokens, completionTokens);
101
+ recordSpend(cost);
99
102
  return { text, model, role, promptTokens, completionTokens, cost, latencyMs, mocked: false };
100
103
  }
101
104
 
@@ -113,6 +116,7 @@ export async function chatStream({ model, role, messages, maxTokens = 220, tempe
113
116
  return { text, model, role, promptTokens, completionTokens, cost: estimateCost(model, promptTokens, completionTokens), latencyMs: Date.now() - t0, mocked: true };
114
117
  }
115
118
 
119
+ if (dailyCapReached()) throw new DailyCapError();
116
120
  const res = await fetch(OR_URL, {
117
121
  method: "POST",
118
122
  headers: {
@@ -149,5 +153,6 @@ export async function chatStream({ model, role, messages, maxTokens = 220, tempe
149
153
  const promptTokens = usage?.prompt_tokens ?? 0;
150
154
  const completionTokens = usage?.completion_tokens ?? 0;
151
155
  const cost = typeof usage?.cost === "number" ? usage.cost : estimateCost(model, promptTokens, completionTokens);
156
+ recordSpend(cost);
152
157
  return { text, model, role, promptTokens, completionTokens, cost, latencyMs: Date.now() - t0, mocked: false };
153
158
  }
package/lib/ours.mjs CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  import { chat, chatStream, estTokens } from "./openrouter.mjs";
8
8
  import { MODELS, PROVIDER } from "./models.mjs";
9
+ import * as demo from "./mock.mjs";
10
+ import { emitEvent, specialistUrl } from "./webhooks.mjs";
9
11
  import { record, scoreView, addTokens, mintSpecialistKey, revokeSpecialistKey } from "./state.mjs";
10
12
 
11
13
  const HAMMING_SAME = 20; // on the 2048-bit (32×32) dHash — ~1%; a tab switch or text change flips more
@@ -121,13 +123,6 @@ function similar(a, b) {
121
123
  function activityLine(o) { return o.activity.length ? o.activity.slice(-6).map((a) => "• " + a.action).join("\n") : "—"; }
122
124
 
123
125
  /* ---------- 1. Perception (diff-gated, rich) ---------- */
124
- function mockVision(userText) {
125
- const t = (userText || "").toLowerCase();
126
- let app = "OpenRouter", page = "Credits";
127
- if (/ai studio|gemini/.test(t)) { app = "Google AI Studio"; page = "Playground"; }
128
- else if (/gmail|inbox|email/.test(t)) { app = "Gmail"; page = "Inbox"; }
129
- return JSON.stringify({ app, page, elements: ["Manage", "Auto Top-Up: enabled", "Add Credits (disabled)"], state: "Auto top-up is on; Add Credits looks greyed out", look: "clean light UI, indigo accents, roomy spacing — feels friendly and modern", changed: ["screen updated"] });
130
- }
131
126
  // Serialize perception per session: /api/observe and /api/turn can arrive
132
127
  // concurrently, and both write screenState/lastHash — unserialized, a slow
133
128
  // response can overwrite a fresher read and clobber the diff-gate hash.
@@ -150,7 +145,7 @@ async function doPerceive(o, frameDataUrl, screenActive, frameHash, userText, us
150
145
  { role: "system", content: "Read this app screenshot for an assistant that will guide the user (the assistant does NOT see the image — your JSON is its only eyes, including for OPINIONS about the design AND for naming actual content on screen). Output ONLY minified JSON: {\"app\":string,\"page\":string,\"elements\":[\"up to 14 KEY buttons/links/toggles/fields — EXACT on-screen text + state like (disabled)/(selected)/(greyed)\"],\"content\":[\"up to 16 of the MAIN CONTENT items actually visible, copied VERBATIM and IN THE ORDER SHOWN top-to-bottom (the FIRST entry is the topmost/newest). Include identifying detail: for a list/feed (emails, videos, results) give each row as 'primary — secondary — time/meta', e.g. email row 'The Daily Byte — Your daily AI news — 9:12 AM', video 'Se7en Review — Nerdwriter — 1.2M views'. COMPARISON / PRICING / PLAN layouts (side-by-side cards, tables): capture EVERY tier/column as its own entry with ALL its key numbers — name, price WITH period, AND the headline quota/limit like credits or seats per period, e.g. 'Pro — $99/mo — 500,000 credits/mo'. NEVER drop the quota/credits in favor of the price — small or low-contrast numbers still count. For an OPENED item being VIEWED (an email, doc, video) capture its header FULLY: sender NAME and their email ADDRESS if shown (e.g. 'The Daily Byte <news@dailybyte.example>'), the subject/title, and the date. Never invent — copy only what is truly on screen; [] if none\"],\"state\":\"one line: what's shown, WHICH TAB/SECTION is selected, key values/numbers\",\"look\":\"1-2 lines on the VISUAL design: dominant colors (names + hex if shown), typography and layout feel, density, overall vibe\",\"activity\":\"short phrase: what the user most likely just DID vs the PREVIOUS screen, or 'no real change'\"}. Copy on-screen text VERBATIM; never guess text that isn't visible; keep each entry terse." },
151
146
  { role: "user", content: [{ type: "image_url", image_url: { url: frameDataUrl } }, { type: "text", text: `PREVIOUS screen was: ${prevSummary}\nRead the CURRENT screen and infer the activity (what the user just did).` }] },
152
147
  ];
153
- const out = await chat({ model: MODELS.perception, role: "perception", provider: PROVIDER.perception, messages, maxTokens: 1100, temperature: 0.1, mock: () => mockVision(userText) });
148
+ const out = await chat({ model: MODELS.perception, role: "perception", provider: PROVIDER.perception, messages, maxTokens: 1100, temperature: 0.1, mock: () => demo.vision() });
154
149
  usages.push(out);
155
150
  const parsed = parseJSON(out.text, null);
156
151
  const usable = parsed && ((parsed.elements && parsed.elements.length) || (parsed.state && String(parsed.state).length > 3) || (parsed.app && !/^unknown/i.test(parsed.app)));
@@ -208,7 +203,7 @@ async function think(o, userText, usages) {
208
203
  { role: "system", content: THINK_SYS },
209
204
  { role: "user", content: `GOAL: ${o.goal}\nSCREEN NOW: ${screenLine(o, true)}\nRECENT ACTIVITY (newest last):\n${activityLine(o)}\nCONVERSATION:\n${recent}\nYOUR RECENT INSTRUCTIONS (don't just repeat these): ${myLines}\nUSER JUST SAID: "${userText}"` },
210
205
  ];
211
- const out = await chat({ model: MODELS.intent, role: "thinking", messages, maxTokens: 150, temperature: 0.2, mock: () => JSON.stringify({ resolved: false, stuck: false, blocker: "", thought: "Following the sign-in steps — screen is advancing, nothing stuck yet.", nextTactic: "" }) });
206
+ const out = await chat({ model: MODELS.intent, role: "thinking", messages, maxTokens: 150, temperature: 0.2, mock: () => demo.thinking() });
212
207
  usages.push(out);
213
208
  const p = parseJSON(out.text, { resolved: false, stuck: false, blocker: "", thought: "", nextTactic: "" });
214
209
  o.lastThink = { resolved: !!p.resolved, stuck: !!p.stuck, blocker: p.blocker || "", thought: p.thought || "", nextTactic: p.nextTactic || "" };
@@ -226,7 +221,7 @@ function buildWorkflowFromPlan(o) {
226
221
  if (o.workflow || !o.plan || !o.plan.length) return;
227
222
  o.workflow = { title: o.goal && o.goal !== "—" ? cap(o.goal) : "Your task", steps: o.plan.map((t, i) => ({ title: cap(t), status: i === 0 ? "active" : "todo" })), current: 0, etaMin: null, note: "" };
228
223
  }
229
- function applyWorkflow(o, p) {
224
+ export function applyWorkflow(o, p) { // exported for tests
230
225
  const wf = o.workflow; if (!wf || !p) return;
231
226
  const n = wf.steps.length;
232
227
  let cur = Number.isInteger(p.current) ? p.current : wf.current;
@@ -252,6 +247,23 @@ function applyWorkflow(o, p) {
252
247
  wf.complete = wf.steps.length > 0 && wf.steps.every((s) => s.status === "done");
253
248
  o.done = wf.complete;
254
249
  if (!o.done) o.doneAnnounced = false; // regressed → allow a fresh wrap-up later
250
+ reportProgress(o);
251
+ }
252
+ // Each step (and the whole journey) is reported once, the first time it's done on
253
+ // screen. The tracker can regress, but a webhook receiver shouldn't see repeats.
254
+ function reportProgress(o) {
255
+ const wf = o.workflow;
256
+ wf.reported ??= new Set();
257
+ const total = wf.steps.length;
258
+ wf.steps.forEach((s, i) => {
259
+ if (s.status !== "done" || wf.reported.has(i)) return;
260
+ wf.reported.add(i);
261
+ emitEvent("step.completed", { sessionId: o.sessionId, workflow: wf.title, step: { index: i, title: s.title }, done: wf.reported.size, total });
262
+ });
263
+ if (wf.complete && !wf.reportedComplete) {
264
+ wf.reportedComplete = true;
265
+ emitEvent("workflow.completed", { sessionId: o.sessionId, workflow: wf.title, total, platform: o.platform ? { id: o.platform.id, name: o.platform.name } : null });
266
+ }
255
267
  }
256
268
  async function trackWorkflow(o, usages) {
257
269
  if (!o.workflow) return null;
@@ -342,16 +354,13 @@ function compileContext(o, route, intent, userText, screenActive) {
342
354
  }
343
355
 
344
356
  /* ---------- 4. Planner (async: goal + grounded plan) ---------- */
345
- function mockPlanner(o) {
346
- return JSON.stringify({ goal: "add credits manually on " + (o.screenState?.app || "the site"), understanding: "user wants to top up manually", plan: ["disable auto top-up", "find Add Credits", "enter amount & pay"], guidance: "be precise and grounded in the screen" });
347
- }
348
357
  async function plan(o, usages) {
349
358
  const hist = o.history.slice(-10).map((t) => `${t.role}: ${t.text}`).join("\n");
350
359
  const messages = [
351
360
  { role: "system", content: 'You are the planner for a screen-aware assistant. Re-derive the goal from the user\'s LATEST messages and the CURRENT screen — the newest signals always outweigh older ones. If the user has pivoted to a new task, topic, or app, REPLACE the goal and plan entirely; never carry forward a goal the user has moved past. If there is no clear task yet, keep the goal broad ("help with whatever they ask") rather than inventing one. Output ONLY JSON: {"goal":"<one line: the concrete thing the user is trying to accomplish RIGHT NOW>","understanding":"<one line>","plan":["<=3 short next steps, grounded in real UI>"],"guidance":"<one line tone/pace note>"}.' },
352
361
  { role: "user", content: `Screen: ${o.screenState && !o.screenState.stale ? o.screenState.app + " · " + o.screenState.page + " — " + (o.screenState.state || "") : "not shared"}\nVisible: ${(o.screenState?.elements || []).join(" | ")}\nRecent activity:\n${activityLine(o)}\nConversation:\n${hist}` },
353
362
  ];
354
- const out = await chat({ model: MODELS.planner, role: "planner", messages, maxTokens: 200, temperature: 0.4, mock: () => mockPlanner(o) });
363
+ const out = await chat({ model: MODELS.planner, role: "planner", messages, maxTokens: 200, temperature: 0.4, mock: () => demo.planner(o) });
355
364
  usages.push(out);
356
365
  const p = parseJSON(out.text, null);
357
366
  if (p) { o.goal = p.goal || o.goal; o.understanding = p.understanding || o.understanding; o.plan = Array.isArray(p.plan) ? p.plan.slice(0, 3) : o.plan; o.guidance = p.guidance || o.guidance; }
@@ -363,14 +372,6 @@ async function plan(o, usages) {
363
372
  return out;
364
373
  }
365
374
 
366
- function mockTalker(messages) {
367
- const c = messages[messages.length - 1].content;
368
- const txt = Array.isArray(c) ? (c.find((x) => x.type === "text")?.text || "") : c;
369
- if (/GREET/.test(txt)) return "Hey! I'm Ava — I can see your screen and help with pretty much anything on it. Share a window whenever you like, or just tell me what you're working on.";
370
- if (/MODE: SUPPORT/.test(txt)) return "I've got you — looking at your screen now. Tap 'Manage' next to Auto Top-Up; the disable option is inside there.";
371
- if (/MODE: WRAP/.test(txt)) return "All good — we can pick this up whenever. I'll be right here.";
372
- return "Looking at your screen — tap 'Manage' next to Auto Top-Up, then choose Disable. That unlocks the manual Add Credits option.";
373
- }
374
375
 
375
376
  /* ---------- greeting ---------- */
376
377
  export async function greetStream(sess, emit, voiceMode = false) {
@@ -381,7 +382,7 @@ export async function greetStream(sess, emit, voiceMode = false) {
381
382
  : "GREET: open the call. In 1–2 short sentences, warmly introduce yourself as a screen-aware assistant that can help with anything on their screen, and invite them to share their screen or say what they're working on.";
382
383
  const messages = [{ role: "system", content: sys }, { role: "user", content: greetMsg }];
383
384
  emit("talkerStart", { model: MODELS.talker });
384
- const tk = await chatStream({ model: MODELS.talker, role: "talker", provider: PROVIDER.talker, messages, maxTokens: 120, temperature: 0.6, mock: mockTalker, onDelta: (d) => emit("delta", { text: d }) });
385
+ const tk = await chatStream({ model: MODELS.talker, role: "talker", provider: PROVIDER.talker, messages, maxTokens: 120, temperature: 0.6, mock: () => demo.greet(sess.ours), onDelta: (d) => emit("delta", { text: d }) });
385
386
  tk.text = sanitizeReply(stripLabel(tk.text));
386
387
  usages.push(tk); remember(sess.ours, "assistant", tk.text); pushBridge(sess, "ava", tk.text);
387
388
  emit("talkerDone", { text: tk.text, model: MODELS.talker, prompt: tk.promptTokens, output: tk.completionTokens, tokens: tk.promptTokens + tk.completionTokens, latencyMs: tk.latencyMs, cost: tk.cost });
@@ -494,7 +495,7 @@ export async function oursTurnStream(sess, userText, frameDataUrl, screenActive,
494
495
  const sys = talkerSystem(o, voiceMode);
495
496
  let tk;
496
497
  try {
497
- tk = await chatStream({ model: MODELS.talker, role: "talker", provider: PROVIDER.talker, messages: [{ role: "system", content: sys }, { role: "user", content: compiled }], maxTokens: voiceMode ? 140 : 200, temperature: voiceMode ? 0.65 : 0.5, mock: mockTalker, onDelta: (d) => emit("delta", { text: d }) });
498
+ tk = await chatStream({ model: MODELS.talker, role: "talker", provider: PROVIDER.talker, messages: [{ role: "system", content: sys }, { role: "user", content: compiled }], maxTokens: voiceMode ? 140 : 200, temperature: voiceMode ? 0.65 : 0.5, mock: () => demo.reply(o, userText, route.mode), onDelta: (d) => emit("delta", { text: d }) });
498
499
  } catch (e) {
499
500
  // The talker failed or the client hung up mid-reply. Calls that already ran
500
501
  // (vision, intent, thinking) were billed — count them toward the budget.
@@ -631,8 +632,19 @@ export async function observeUpdate(sess, frameDataUrl, frameHash, force = false
631
632
  // believable connecting → waiting state. (No live agent backend here.)
632
633
  export function requestHandoff(sess) {
633
634
  const o = sess.ours;
634
- if (!o.handoff.requested) o.handoff = { requested: true, state: "connecting", at: Date.now() };
635
- return { handoff: o.handoff, specialistKey: mintSpecialistKey(sess), workflow: o.workflow ? workflowView(o) : null };
635
+ const first = !o.handoff.requested;
636
+ if (first) o.handoff = { requested: true, state: "connecting", at: Date.now() };
637
+ const specialistKey = mintSpecialistKey(sess);
638
+ if (first) {
639
+ const wf = o.workflow;
640
+ emitEvent("handoff.requested", {
641
+ sessionId: sess.id, specialistUrl: specialistUrl(specialistKey), mode: o.mode,
642
+ platform: o.platform ? { id: o.platform.id, name: o.platform.name } : null, goal: o.goal !== "—" ? o.goal : null,
643
+ workflow: wf ? { title: wf.title, currentStep: wf.steps[wf.current]?.title || null, done: wf.steps.filter((s) => s.status === "done").length, total: wf.steps.length } : null,
644
+ lastUserMessage: [...o.history].reverse().find((t) => t.role === "user")?.text?.slice(0, 200) || null,
645
+ });
646
+ }
647
+ return { handoff: o.handoff, specialistKey, workflow: o.workflow ? workflowView(o) : null };
636
648
  }
637
649
  export function handoffStatus(sess) {
638
650
  const o = sess.ours;
@@ -710,7 +722,7 @@ async function guessIntention(o, usages) {
710
722
  { role: "system", content: 'From a user\'s screen journey, infer what they are most likely TRYING TO DO overall — a concrete task, not a description of where they are. Output ONLY JSON: {"goal":"<one concrete line, e.g. \'top up their OpenRouter credits\'>","understanding":"<one line: where they are in that journey>","confidence":<0..1>}. Ambiguous journey → confidence below 0.5.' },
711
723
  { role: "user", content: `Screens/actions (oldest first): ${acts}\nCurrent screen: ${screenLine(o, true)}\nPrevious guess: ${o.goal}` },
712
724
  ];
713
- const out = await chat({ model: MODELS.intent, role: "intent", messages, maxTokens: 90, temperature: 0.3, mock: () => JSON.stringify({ goal: "top up their OpenRouter credits", understanding: "circling the billing pages", confidence: 0.8 }) });
725
+ const out = await chat({ model: MODELS.intent, role: "intent", messages, maxTokens: 90, temperature: 0.3, mock: () => demo.guess() });
714
726
  usages.push(out);
715
727
  o.lastGuessAt = Date.now();
716
728
  const p = parseJSON(out.text, null);
@@ -753,7 +765,7 @@ export async function proactiveNudge(sess) {
753
765
  { role: "system", content: momentSys },
754
766
  { role: "user", content: `SCREEN NOW: ${screenLine(o, true)}\nGOAL${o.goalGuessed ? " (your inference from their journey — engage with it, confirm it naturally when useful)" : ""}: ${o.goal}\nPLAN: ${planLine}\nRECENT ACTIVITY (newest last — this is what just happened):\n${activityLine(o)}\nYOU LAST SPOKE: ${since}. Your recent lines (never repeat these): ${recentMine.join(" / ") || "—"}${directive}` },
755
767
  ];
756
- const out = await chat({ model: MODELS.talker, role: "talker", provider: PROVIDER.talker, messages, maxTokens: 60, temperature: 0.7, mock: () => "Want a hand with anything here?" });
768
+ const out = await chat({ model: MODELS.talker, role: "talker", provider: PROVIDER.talker, messages, maxTokens: 60, temperature: 0.7, mock: () => demo.nudge(o) });
757
769
  usages.push(out); addTokens(sess, "ours", usages);
758
770
  const t = sanitizeReply(stripLabel(out.text));
759
771
  const base = { goal: o.goal, understanding: o.understanding, goalGuessedNow: !!guessedNow, workflow: o.workflow ? workflowView(o) : null, session: scoreView(sess.score.ours) };
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
- export const PLATFORMS = [
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?.specialistKey) revokeSpecialistKey(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
- revokeSpecialistKey(s);
112
- drop(id);
121
+ drop(id, "hangup");
113
122
  return true;
114
123
  }
115
124
  export function getSessionBySpecialistKey(key) {
@@ -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.2.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": [
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 CHANGED
@@ -54,6 +54,9 @@ input.short { max-width: 160px; flex: none; }
54
54
  .keep label, .check { font-weight: 400 !important; display: flex; gap: 10px; align-items: center; font-size: 15px; }
55
55
  .keep input, .check input { width: 18px; height: 18px; accent-color: var(--accent); }
56
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); }
57
60
  .status { margin: 0; min-height: 22px; font-size: 14.5px; }
58
61
  .status.ok { color: var(--ok); } .status.err { color: var(--err); }
59
62
  .banner { padding: 14px 18px; border-radius: 12px; margin-bottom: 24px; }
package/public/setup.html CHANGED
@@ -40,6 +40,12 @@
40
40
  <div class="field" id="keyField">
41
41
  <div id="keepKeyRow" class="keep" hidden><label><input type="checkbox" id="keepKey" checked> Keep my current key <code id="currentKey"></code></label></div>
42
42
  <div id="newKeyRow">
43
+ <div class="connect">
44
+ <button type="button" class="btn primary" id="connectOr">Connect OpenRouter</button>
45
+ <p class="hint">Sign in to OpenRouter and approve, and Ava gets its own key. Nothing to copy.</p>
46
+ <p class="status" id="connectStatus" role="status" aria-live="polite"></p>
47
+ </div>
48
+ <p class="or"><span>or paste a key</span></p>
43
49
  <label for="orKey">OpenRouter API key</label>
44
50
  <div class="row">
45
51
  <input id="orKey" type="password" autocomplete="off" spellcheck="false" placeholder="sk-or-v1-…">
package/public/setup.js CHANGED
@@ -39,7 +39,7 @@
39
39
  }
40
40
  function canLeave(step) {
41
41
  if (step === "ai" && radio("mode") === "full" && !(S.state.current.openrouterKey && $("keepKey").checked) && !S.keyOk) {
42
- setStatus($("keyStatus"), "Check your key first, or choose “Just look around first”.", "err"); return false;
42
+ setStatus($("keyStatus"), "Connect OpenRouter or check a key first, or choose “Just look around first”.", "err"); return false;
43
43
  }
44
44
  if (step === "voice" && radio("voice") === "elevenlabs" && !(S.state.current.elevenKey && $("keepEleven").checked) && !S.elevenOk) {
45
45
  setStatus($("elevenStatus"), "Check your ElevenLabs key first, or pick another voice.", "err"); return false;
@@ -56,6 +56,30 @@
56
56
  });
57
57
 
58
58
  // ── 1 · AI ──
59
+ // Connect OpenRouter: sign-in opens in a new tab; the setup server receives the
60
+ // key and keeps it. This page only learns that it worked, plus a masked key.
61
+ async function connectOpenRouter() {
62
+ const tab = window.open("about:blank", "_blank"); // open now, while the click still counts, so it isn't blocked
63
+ setStatus($("connectStatus"), "Opening OpenRouter…");
64
+ try {
65
+ const { url } = await api("openrouter/start", {});
66
+ if (tab) { tab.opener = null; tab.location = url; } else location.assign(url);
67
+ setStatus($("connectStatus"), "Waiting for you to approve in the OpenRouter tab…");
68
+ clearInterval(S.connectTimer);
69
+ S.connectTimer = setInterval(async () => {
70
+ let r; try { r = await api("openrouter/status"); } catch { return; }
71
+ if (r.status === "connected") {
72
+ clearInterval(S.connectTimer);
73
+ S.connected = true; S.keyOk = true; $("orKey").value = "";
74
+ setStatus($("connectStatus"), `✓ Connected (${r.key}). Set a credit limit on it at openrouter.ai/keys.`, "ok");
75
+ setStatus($("keyStatus"), "");
76
+ } else if (r.status === "error") {
77
+ clearInterval(S.connectTimer);
78
+ setStatus($("connectStatus"), `✗ ${r.error}`, "err");
79
+ }
80
+ }, 1500);
81
+ } catch (e) { tab?.close(); setStatus($("connectStatus"), `✗ ${e.message}`, "err"); }
82
+ }
59
83
  function syncMode() {
60
84
  const full = radio("mode") === "full";
61
85
  $("keyField").hidden = !full;
@@ -163,7 +187,7 @@
163
187
  const mode = radio("mode"), voice = radio("voice"), audience = radio("audience");
164
188
  return {
165
189
  mode,
166
- openrouterKey: mode === "mock" ? null : S.state.current.openrouterKey && $("keepKey").checked ? "keep" : $("orKey").value.trim(),
190
+ openrouterKey: mode === "mock" ? null : S.state.current.openrouterKey && $("keepKey").checked ? "keep" : S.connected && !$("orKey").value.trim() ? "connected" : $("orKey").value.trim(),
167
191
  voice,
168
192
  elevenKey: voice === "elevenlabs" ? (S.state.current.elevenKey && $("keepEleven").checked ? "keep" : $("elKey").value.trim()) : undefined,
169
193
  elevenVoice: voice === "elevenlabs" ? $("elVoice").value.trim() || null : undefined,
@@ -246,7 +270,8 @@
246
270
  $("keepEleven").addEventListener("change", syncVoice);
247
271
  document.querySelectorAll('input[name="audience"]').forEach((i) => i.addEventListener("change", syncAudience));
248
272
  $("keepPass").addEventListener("change", syncAudience);
249
- $("orKey").addEventListener("input", () => { S.keyOk = false; setStatus($("keyStatus"), ""); });
273
+ $("orKey").addEventListener("input", () => { S.keyOk = !!S.connected && !$("orKey").value.trim(); setStatus($("keyStatus"), ""); });
274
+ $("connectOr").addEventListener("click", connectOpenRouter);
250
275
  $("elKey").addEventListener("input", () => { S.elevenOk = false; setStatus($("elevenStatus"), ""); });
251
276
  $("checkKey").addEventListener("click", () => checkKey("openrouter", $("orKey"), $("keyStatus"), "keyOk"));
252
277
  $("checkEleven").addEventListener("click", () => checkKey("elevenlabs", $("elKey"), $("elevenStatus"), "elevenOk"));
package/public/style.css CHANGED
@@ -96,6 +96,10 @@ b { font-weight: 600; }
96
96
  .pill { display: flex; align-items: center; gap: 7px; padding: 5px 11px 5px 9px; border-radius: 999px; font-family: var(--mono); font-size: 10px; font-weight: 500; letter-spacing: 0.1em; }
97
97
  .pill .dot { width: 6px; height: 6px; border-radius: 50%; }
98
98
  .pill.standby { background: var(--control); border: 1px solid var(--edge); color: var(--ink2); }
99
+ .pill.demo { background: #FFF6E5; border: 1px solid #F2D49A; color: #7A4B00; }
100
+ .pill.demo[hidden], .ob-demo[hidden] { display: none; }
101
+ .ob-demo { margin-top: 12px; padding: 10px 14px; border-radius: 10px; background: #FFF6E5; border: 1px solid #F2D49A; color: #5C3A00; font-size: 13.5px; }
102
+ .ob-demo code { font-family: var(--mono); font-size: 12.5px; }
99
103
  .pill.standby .dot { border: 1px solid var(--meta); }
100
104
  .pill.live { background: var(--brandtint); border: 1px solid var(--brandline); color: var(--amber); }
101
105
  .pill.live .dot { background: var(--amber); }
package/server.mjs CHANGED
@@ -12,8 +12,10 @@ import { oursTurnStream, greetStream, observeUpdate, screenOff, proactiveNudge,
12
12
  import { synth, TTS_ENABLED, providers, defaultProvider, warmKokoro, kokoroStream, KOKORO_STREAMS, elevenStream, ELEVEN_STREAMS, TtsBusyError } from "./lib/tts.mjs";
13
13
  import { transcribe, STT_ENABLED } from "./lib/stt.mjs";
14
14
  import { createAsrSession, asrAvailable, warmAsr, availableEngines, defaultEngine, ENGINE_LABELS } from "./lib/asr.mjs";
15
- import { platformsView, platformById } from "./lib/platforms.mjs";
15
+ import { platformsView, platformById, CUSTOM_PLAYBOOKS } from "./lib/platforms.mjs";
16
16
  import { AUTH_ENABLED, isAuthorized, challengeHeaders, authCookieHeader, clientIp, sameOrigin, limiter } from "./lib/guard.mjs";
17
+ import { emitEvent, WEBHOOKS_ENABLED } from "./lib/webhooks.mjs";
18
+ import { DAILY_BUDGET_USD, dailyCapReached, spentToday } from "./lib/spend.mjs";
17
19
  import { WebSocketServer } from "ws";
18
20
 
19
21
  // A stray rejection is logged, not fatal. An uncaught exception leaves the process
@@ -93,6 +95,7 @@ function send(res, code, body, headers = {}) {
93
95
  // Upstream errors (OpenRouter, ElevenLabs, Deepgram…) can carry account or quota
94
96
  // detail. Log it here; the browser only gets a short, generic reason.
95
97
  function upstreamFail(res, e, what) {
98
+ if (e?.code === "daily_cap") return send(res, 503, { error: CAP_MSG }, { "Retry-After": untilMidnightUtc() });
96
99
  console.error(` [${what}]`, e?.message || e);
97
100
  return send(res, e?.status === 429 ? 429 : 502, { error: `${what} failed` });
98
101
  }
@@ -125,7 +128,14 @@ function readBody(req, limit = 3 * 1024 * 1024) {
125
128
 
126
129
  function safeHost(req) { const h = String(req.headers.host || ""); return /^[a-z0-9.-]+(:\d+)?$/i.test(h) ? h : ""; }
127
130
  function validFrame(f) { return typeof f === "string" && f.length <= MAX_FRAME_BYTES && FRAME_RE.test(f) ? f : null; }
128
- function overBudget(sess) { return sess.score.ours.cost >= SESSION_BUDGET_USD; }
131
+ const CAP_MSG = "Ava has reached today's usage limit — try again tomorrow";
132
+ /** Why a session can't spend more (its own budget, or the daily cap across all sessions), or null. */
133
+ function spendLimit(sess) {
134
+ if (!MOCK && dailyCapReached()) return CAP_MSG;
135
+ if (sess.score.ours.cost >= SESSION_BUDGET_USD) return "this session reached its spend limit — start a new call";
136
+ return null;
137
+ }
138
+ const untilMidnightUtc = () => String(Math.ceil((new Date().setUTCHours(24, 0, 0, 0) - Date.now()) / 1000));
129
139
 
130
140
  async function serveStatic(req, res, pathname) {
131
141
  let p;
@@ -201,6 +211,7 @@ async function handle(req, res) {
201
211
  if (req.method === "GET" && url.pathname === "/api/health") {
202
212
  return send(res, 200, {
203
213
  mock: MOCK,
214
+ dailyCapReached: !MOCK && dailyCapReached(),
204
215
  models: { talker: MODELS.talker, perception: MODELS.perception, planner: MODELS.planner, intent: MODELS.intent },
205
216
  labels: ROLE_LABEL,
206
217
  tts: { enabled: TTS_ENABLED, providers: providers(), default: defaultProvider() },
@@ -285,11 +296,13 @@ async function handle(req, res) {
285
296
  if (req.method === "POST" && url.pathname === "/api/session") {
286
297
  if (!rate.session(clientIp(req))) return send(res, 429, { error: "too many new sessions — try again in a minute" }, { "Retry-After": "60" });
287
298
  if (!hasCapacity()) return send(res, 503, { error: "Ava is at capacity right now — try again in a few minutes" }, { "Retry-After": "60" });
299
+ if (!MOCK && dailyCapReached()) return send(res, 503, { error: CAP_MSG }, { "Retry-After": untilMidnightUtc() });
288
300
  const body = await readBody(req);
289
301
  const mode = body.mode === "onboarding" ? "onboarding" : "flexible";
290
302
  const platform = typeof body.platformId === "string" ? platformById(body.platformId) : null; // knowledge in any mode
291
303
  const s = newSession({ mode, platform });
292
304
  if (!s) return send(res, 503, { error: "Ava is at capacity right now — try again in a few minutes" }, { "Retry-After": "60" });
305
+ emitEvent("session.started", { sessionId: s.id, mode, platform: platform ? { id: platform.id, name: platform.name } : null });
293
306
  // pre-warm the talker route so the greeting isn't a cold start
294
307
  if (!MOCK) chat({ model: MODELS.talker, role: "talker", provider: PROVIDER.talker, messages: [{ role: "user", content: "hi" }], maxTokens: 1 }).catch(() => {});
295
308
  return send(res, 200, { sessionId: s.id, mock: MOCK, models: MODELS, mode, platform: platform ? { id: platform.id, name: platform.name, url: platform.url } : null });
@@ -300,7 +313,8 @@ async function handle(req, res) {
300
313
  const { sessionId, frameHash = null, greet = false, voice = false, changed = false, screenActive = false } = body;
301
314
  const sess = getSession(sessionId);
302
315
  if (!sess) return send(res, 404, { error: "unknown session" });
303
- if (overBudget(sess)) return send(res, 402, { error: "this session reached its spend limit — start a new call" });
316
+ const limit = spendLimit(sess);
317
+ if (limit) return send(res, 402, { error: limit });
304
318
  const userText = String(body.userText ?? "").slice(0, MAX_USER_TEXT);
305
319
  const frame = validFrame(body.frame);
306
320
 
@@ -310,8 +324,8 @@ async function handle(req, res) {
310
324
  if (greet) await greetStream(sess, emit, !!voice);
311
325
  else await oursTurnStream(sess, userText, frame, !!screenActive, frameHash, emit, !!voice, !!changed);
312
326
  } catch (e) {
313
- console.error(" [turn]", e?.message || e);
314
- emit("error", { error: "Ava couldn't answer that one — try again" });
327
+ if (e?.code === "daily_cap") emit("error", { error: CAP_MSG });
328
+ else { console.error(" [turn]", e?.message || e); emit("error", { error: "Ava couldn't answer that one — try again" }); }
315
329
  }
316
330
  res.end();
317
331
  return;
@@ -322,7 +336,7 @@ async function handle(req, res) {
322
336
  const sess = getSession(sessionId);
323
337
  if (!sess) return send(res, 404, { error: "unknown session" });
324
338
  if (stopped) return send(res, 200, screenOff(sess)); // user stopped sharing → mark state stale now
325
- if (overBudget(sess)) return send(res, 402, { error: "spend limit reached" });
339
+ if (spendLimit(sess)) return send(res, 402, { error: spendLimit(sess) });
326
340
  const frame = validFrame(rawFrame);
327
341
  if (!frame) return send(res, 400, { error: "no frame" });
328
342
  try { return send(res, 200, await observeUpdate(sess, frame, frameHash, !!changed)); }
@@ -382,7 +396,7 @@ async function handle(req, res) {
382
396
  const { sessionId } = await readBody(req);
383
397
  const sess = getSession(sessionId);
384
398
  if (!sess) return send(res, 404, { error: "unknown session" });
385
- if (overBudget(sess)) return send(res, 402, { error: "spend limit reached" });
399
+ if (spendLimit(sess)) return send(res, 402, { error: spendLimit(sess) });
386
400
  try { return send(res, 200, await proactiveNudge(sess)); }
387
401
  catch (e) { return upstreamFail(res, e, "nudge"); }
388
402
  }
@@ -493,6 +507,9 @@ server.listen(PORT, HOST, () => {
493
507
  console.log(` → http://localhost:${PORT}/app`);
494
508
  console.log(` mode: ${MOCK ? "MOCK (no OPENROUTER_API_KEY set — synthetic replies)" : "LIVE (OpenRouter)"}`);
495
509
  if (!MOCK) console.log(` models: talker=${MODELS.talker} perception=${MODELS.perception} planner=${MODELS.planner} intent=${MODELS.intent}`);
510
+ if (!MOCK) console.log(DAILY_BUDGET_USD ? ` daily cap: $${DAILY_BUDGET_USD} (spent today: $${spentToday().toFixed(2)})` : " daily cap: none (set DAILY_BUDGET_USD to limit spend across all calls)");
511
+ if (CUSTOM_PLAYBOOKS.count) console.log(` playbooks: ${CUSTOM_PLAYBOOKS.count} from ${CUSTOM_PLAYBOOKS.dir}`);
512
+ if (WEBHOOKS_ENABLED) console.log(" webhooks: on");
496
513
  if (LOCAL_ONLY) console.log(" access: this computer only");
497
514
  else if (!AUTH_ENABLED) console.log(" access: OPEN — anyone who can reach this server can spend your API credit. Set ACCESS_PASSWORD before exposing it.");
498
515
  else console.log(" access: password protected");