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 +11 -0
- package/README.md +3 -1
- package/lib/cli/main.mjs +34 -1
- package/lib/cli/openrouter-oauth.mjs +87 -0
- package/lib/cli/setup-server.mjs +29 -2
- package/lib/cli/setup.mjs +26 -2
- package/lib/mock.mjs +78 -0
- package/lib/openrouter.mjs +5 -0
- package/lib/ours.mjs +40 -28
- package/lib/platforms.mjs +15 -1
- package/lib/playbooks.mjs +111 -0
- package/lib/spend.mjs +59 -0
- package/lib/state.mjs +14 -5
- package/lib/webhooks.mjs +68 -0
- package/package.json +1 -1
- package/public/app.js +2 -0
- package/public/index.html +2 -0
- package/public/setup.css +3 -0
- package/public/setup.html +6 -0
- package/public/setup.js +28 -3
- package/public/style.css +4 -0
- package/server.mjs +24 -7
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
|
|
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
|
+
}
|
package/lib/cli/setup-server.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/lib/openrouter.mjs
CHANGED
|
@@ -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: () =>
|
|
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: () =>
|
|
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: () =>
|
|
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:
|
|
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:
|
|
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
|
-
|
|
635
|
-
|
|
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: () =>
|
|
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: () =>
|
|
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
|
-
|
|
9
|
+
import { loadPlaybooks, mergePlaybooks, PLAYBOOKS_DIR } from "./playbooks.mjs";
|
|
10
|
+
|
|
11
|
+
export const BUILTIN_PLATFORMS = [
|
|
7
12
|
{
|
|
8
13
|
id: "todoist", name: "Todoist", url: "https://app.todoist.com",
|
|
9
14
|
category: "Productivity", accent: "#E44332",
|
|
@@ -194,6 +199,15 @@ export const PLATFORMS = [
|
|
|
194
199
|
},
|
|
195
200
|
];
|
|
196
201
|
|
|
202
|
+
const custom = loadPlaybooks();
|
|
203
|
+
for (const p of custom.problems) {
|
|
204
|
+
for (const e of p.errors) console.warn(` [playbooks] ${p.file} skipped: ${e}`);
|
|
205
|
+
for (const w of p.warnings) console.warn(` [playbooks] ${p.file}: ${w}`);
|
|
206
|
+
}
|
|
207
|
+
const hideBuiltins = /^(off|0|false|no)$/i.test(process.env.BUILTIN_PLAYBOOKS || "");
|
|
208
|
+
export const PLATFORMS = mergePlaybooks(BUILTIN_PLATFORMS, custom.playbooks, { hideBuiltins });
|
|
209
|
+
export const CUSTOM_PLAYBOOKS = { count: custom.playbooks.length, dir: PLAYBOOKS_DIR };
|
|
210
|
+
|
|
197
211
|
export function platformById(id) { return PLATFORMS.find((p) => p.id === id) || null; }
|
|
198
212
|
// Trimmed shape for the onboarding picker in the client (demo-only entries hidden).
|
|
199
213
|
// Demo-only (fictional) products are included, flagged, so demo mode can select
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Playbooks: the products Ava can onboard people onto, as JSON files you own.
|
|
2
|
+
//
|
|
3
|
+
// Put one file per product in PLAYBOOKS_DIR (default ~/.ava-engine/playbooks), for
|
|
4
|
+
// example acme.json. A file whose id matches a built-in product replaces it.
|
|
5
|
+
// BUILTIN_PLAYBOOKS=off hides the built-in sample products, so the lobby shows only
|
|
6
|
+
// yours. Files are checked when Ava starts: a broken one is skipped with a message
|
|
7
|
+
// saying what to fix, and never stops the server. `ava-engine playbooks` checks
|
|
8
|
+
// them without starting anything.
|
|
9
|
+
|
|
10
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
const home = (p) => p.replace(/^~(?=$|[\\/])/, homedir()); // .env files don't expand ~
|
|
15
|
+
export const PLAYBOOKS_DIR = home(process.env.PLAYBOOKS_DIR || join(process.env.AVA_HOME || join(homedir(), ".ava-engine"), "playbooks"));
|
|
16
|
+
|
|
17
|
+
const ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
18
|
+
const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
19
|
+
const KNOWN = new Set(["id", "name", "url", "category", "accent", "tagline", "firstWin", "path", "optional", "journey", "etaMin", "knowledge", "demoOnly", "$schema"]);
|
|
20
|
+
|
|
21
|
+
/** Starter file for `ava-engine playbooks new <id>`. */
|
|
22
|
+
export function template(id) {
|
|
23
|
+
return {
|
|
24
|
+
id, name: "Acme", url: "https://app.acme.example",
|
|
25
|
+
category: "Your product", accent: "#4F46E5",
|
|
26
|
+
tagline: "One line on what the product does and why setup takes a hand.",
|
|
27
|
+
firstWin: "the moment they get value, e.g. their first report open on the dashboard",
|
|
28
|
+
path: [
|
|
29
|
+
"sign in or sign up",
|
|
30
|
+
"create a workspace (name it, keep the Free plan)",
|
|
31
|
+
"connect a data source from the Sources page",
|
|
32
|
+
"open the dashboard and see the first report",
|
|
33
|
+
],
|
|
34
|
+
optional: "Things that can wait until after the first win, e.g. inviting teammates.",
|
|
35
|
+
journey: ["Sign in", "Create a workspace", "Connect a source", "See your first report"],
|
|
36
|
+
etaMin: 5,
|
|
37
|
+
knowledge: [
|
|
38
|
+
{ keywords: "workspace create name plan free", text: "Workspaces hold everything. The Free plan is enough to start and can be upgraded later from Settings → Billing." },
|
|
39
|
+
{ keywords: "source connect csv database import", text: "Sources → Add source connects a database or uploads a CSV. The first sync takes about a minute." },
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const str = (v, max) => typeof v === "string" && v.trim().length > 0 && v.length <= max;
|
|
45
|
+
const strList = (v, maxItems, maxLen) => Array.isArray(v) && v.length <= maxItems && v.every((s) => str(s, maxLen));
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check one playbook. Returns { playbook } (normalized, ready to use) or { errors: [...] }.
|
|
49
|
+
* `fallbackId` is the file name, used when the JSON has no id.
|
|
50
|
+
*/
|
|
51
|
+
export function validatePlaybook(raw, fallbackId) {
|
|
52
|
+
const errors = [], warnings = [];
|
|
53
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { errors: ["the file must contain one JSON object"], warnings };
|
|
54
|
+
const id = raw.id ?? fallbackId;
|
|
55
|
+
if (!ID.test(String(id ?? ""))) errors.push(`"id" must be lowercase letters, digits and dashes, up to 40 characters (got ${JSON.stringify(id)})`);
|
|
56
|
+
if (!str(raw.name, 60)) errors.push(`"name" is required (up to 60 characters)`);
|
|
57
|
+
if (raw.url !== undefined && !(str(raw.url, 300) && /^https?:\/\/[^\s]+$/i.test(raw.url))) errors.push(`"url" must start with http:// or https://`);
|
|
58
|
+
if (raw.accent !== undefined && !HEX.test(String(raw.accent))) errors.push(`"accent" must be a hex colour like "#4F46E5"`);
|
|
59
|
+
for (const k of ["category", "tagline", "firstWin", "optional"]) if (raw[k] !== undefined && !str(raw[k], 300)) errors.push(`"${k}" must be text (up to 300 characters)`);
|
|
60
|
+
if (!strList(raw.journey, 10, 60) || raw.journey.length === 0) errors.push(`"journey" is required: 1 to 10 short step names (up to 60 characters each), shown in the progress tracker`);
|
|
61
|
+
if (raw.path !== undefined && !strList(raw.path, 12, 400)) errors.push(`"path" must be a list of up to 12 how-to lines (up to 400 characters each)`);
|
|
62
|
+
if (raw.etaMin !== undefined && !(Number.isInteger(raw.etaMin) && raw.etaMin >= 1 && raw.etaMin <= 120)) errors.push(`"etaMin" must be a whole number of minutes from 1 to 120`);
|
|
63
|
+
if (raw.demoOnly !== undefined && typeof raw.demoOnly !== "boolean") errors.push(`"demoOnly" must be true or false`);
|
|
64
|
+
let knowledge = [];
|
|
65
|
+
if (raw.knowledge !== undefined) {
|
|
66
|
+
if (!Array.isArray(raw.knowledge) || raw.knowledge.length > 200) errors.push(`"knowledge" must be a list of up to 200 entries`);
|
|
67
|
+
else raw.knowledge.forEach((e, i) => {
|
|
68
|
+
const kw = Array.isArray(e?.keywords) ? e.keywords.join(" ") : e?.keywords ?? e?.k;
|
|
69
|
+
if (!str(e?.text, 1000)) errors.push(`knowledge[${i}]: "text" is required (up to 1000 characters)`);
|
|
70
|
+
else if (!str(kw, 300)) errors.push(`knowledge[${i}]: "keywords" is required: the words a question would use, e.g. "invoice export csv download"`);
|
|
71
|
+
else knowledge.push({ k: kw.toLowerCase(), text: e.text });
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
for (const k of Object.keys(raw)) if (!KNOWN.has(k)) warnings.push(`unknown field "${k}" is ignored`);
|
|
75
|
+
if (errors.length) return { errors, warnings };
|
|
76
|
+
const { name, url, category, accent, tagline, firstWin, optional, etaMin, demoOnly } = raw;
|
|
77
|
+
return {
|
|
78
|
+
warnings,
|
|
79
|
+
playbook: {
|
|
80
|
+
id, name, url, category: category || "Your product", accent, tagline: tagline || "", firstWin: firstWin || "the first step done",
|
|
81
|
+
path: raw.path || [], optional, journey: raw.journey, etaMin: etaMin || null, knowledge, ...(demoOnly ? { demoOnly: true } : {}),
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Read every *.json in `dir`. Returns { playbooks, problems: [{file, errors, warnings}] }. */
|
|
87
|
+
export function loadPlaybooks(dir = PLAYBOOKS_DIR) {
|
|
88
|
+
const playbooks = [], problems = [];
|
|
89
|
+
if (!existsSync(dir)) return { playbooks, problems, dir };
|
|
90
|
+
const seen = new Map();
|
|
91
|
+
for (const file of readdirSync(dir).filter((f) => f.endsWith(".json")).sort()) {
|
|
92
|
+
let raw;
|
|
93
|
+
try { raw = JSON.parse(readFileSync(join(dir, file), "utf8")); }
|
|
94
|
+
catch (e) { problems.push({ file, errors: [`not valid JSON: ${e.message}`], warnings: [] }); continue; }
|
|
95
|
+
const r = validatePlaybook(raw, file.replace(/\.json$/, ""));
|
|
96
|
+
if (r.playbook && seen.has(r.playbook.id)) r.errors = [`id "${r.playbook.id}" is already used by ${seen.get(r.playbook.id)}`];
|
|
97
|
+
if (r.errors?.length) { problems.push({ file, errors: r.errors, warnings: r.warnings }); continue; }
|
|
98
|
+
if (r.warnings.length) problems.push({ file, errors: [], warnings: r.warnings });
|
|
99
|
+
seen.set(r.playbook.id, file);
|
|
100
|
+
playbooks.push({ ...r.playbook, source: file });
|
|
101
|
+
}
|
|
102
|
+
return { playbooks, problems, dir };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Built-ins plus your files: yours replace a built-in with the same id. */
|
|
106
|
+
export function mergePlaybooks(builtins, custom, { hideBuiltins = false } = {}) {
|
|
107
|
+
const ids = new Set(custom.map((p) => p.id));
|
|
108
|
+
// Demo-only built-ins stay: demo mode needs them and the lobby never lists them.
|
|
109
|
+
const kept = builtins.filter((b) => !ids.has(b.id) && (!hideBuiltins || b.demoOnly));
|
|
110
|
+
return [...custom, ...kept];
|
|
111
|
+
}
|
package/lib/spend.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Daily spending cap across every session: DAILY_BUDGET_USD (unset or 0 = no cap).
|
|
2
|
+
//
|
|
3
|
+
// Counts what OpenRouter bills for model calls (its reported usage.cost, or our
|
|
4
|
+
// estimate), per UTC day. Once today's total reaches the cap, new paid calls are
|
|
5
|
+
// refused until midnight UTC. Calls already in flight finish, so the total can
|
|
6
|
+
// overshoot by a few cents. Speech providers (Deepgram, ElevenLabs, Hume) bill
|
|
7
|
+
// separately and aren't counted.
|
|
8
|
+
//
|
|
9
|
+
// The total is saved to SPEND_FILE (default ~/.ava-engine/spend.json) so a restart
|
|
10
|
+
// doesn't reset it. Nothing is written when no cap is set.
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
|
|
16
|
+
export const DAILY_BUDGET_USD = Math.max(0, Number(process.env.DAILY_BUDGET_USD) || 0);
|
|
17
|
+
const FILE = process.env.SPEND_FILE || join(process.env.AVA_HOME || join(homedir(), ".ava-engine"), "spend.json");
|
|
18
|
+
|
|
19
|
+
const today = () => new Date().toISOString().slice(0, 10);
|
|
20
|
+
let ledger = load();
|
|
21
|
+
|
|
22
|
+
function load() {
|
|
23
|
+
if (!DAILY_BUDGET_USD) return { day: today(), usd: 0 };
|
|
24
|
+
try {
|
|
25
|
+
const j = JSON.parse(readFileSync(FILE, "utf8"));
|
|
26
|
+
if (typeof j.day === "string" && Number.isFinite(j.usd) && j.usd >= 0) return { day: j.day, usd: j.usd };
|
|
27
|
+
} catch { /* first run or unreadable: start from zero */ }
|
|
28
|
+
return { day: today(), usd: 0 };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function current() {
|
|
32
|
+
const d = today();
|
|
33
|
+
if (ledger.day !== d) ledger = { day: d, usd: 0 };
|
|
34
|
+
return ledger;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Add the cost of a finished call to today's total. */
|
|
38
|
+
export function recordSpend(usd) {
|
|
39
|
+
if (!(usd > 0)) return;
|
|
40
|
+
const l = current();
|
|
41
|
+
l.usd += usd;
|
|
42
|
+
if (!DAILY_BUDGET_USD) return;
|
|
43
|
+
try {
|
|
44
|
+
if (!existsSync(dirname(FILE))) mkdirSync(dirname(FILE), { recursive: true });
|
|
45
|
+
writeFileSync(FILE, JSON.stringify(l));
|
|
46
|
+
} catch (e) { console.warn(" [spend] couldn't save the daily total:", e.message); }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True once today's spend has reached DAILY_BUDGET_USD. */
|
|
50
|
+
export function dailyCapReached() {
|
|
51
|
+
return DAILY_BUDGET_USD > 0 && current().usd >= DAILY_BUDGET_USD;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Today's total, for logs and tests. */
|
|
55
|
+
export function spentToday() { return current().usd; }
|
|
56
|
+
|
|
57
|
+
export class DailyCapError extends Error {
|
|
58
|
+
constructor() { super("daily spending cap reached"); this.status = 402; this.code = "daily_cap"; }
|
|
59
|
+
}
|
package/lib/state.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// In-memory session store for the flexible agent.
|
|
2
2
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { emitEvent } from "./webhooks.mjs";
|
|
4
5
|
|
|
5
6
|
const sessions = new Map();
|
|
6
7
|
const byKey = new Map(); // specialist key → session id (see mintSpecialistKey)
|
|
@@ -13,16 +14,24 @@ const MAX_SESSIONS = Number(process.env.MAX_SESSIONS) || 50;
|
|
|
13
14
|
const MAX_AGE_MS = 6 * 3600 * 1000;
|
|
14
15
|
const IDLE_MS = 30 * 60 * 1000;
|
|
15
16
|
|
|
16
|
-
function drop(id) {
|
|
17
|
+
function drop(id, reason) {
|
|
17
18
|
const s = sessions.get(id);
|
|
18
|
-
if (s
|
|
19
|
+
if (!s) return;
|
|
20
|
+
if (s.specialistKey) revokeSpecialistKey(s);
|
|
19
21
|
sessions.delete(id);
|
|
22
|
+
const o = s.ours, wf = o.workflow;
|
|
23
|
+
emitEvent("session.ended", {
|
|
24
|
+
sessionId: id, reason, mode: o.mode, platform: o.platform ? { id: o.platform.id, name: o.platform.name } : null,
|
|
25
|
+
durationSec: Math.round((Date.now() - s.createdAt) / 1000), turns: s.score.ours.turns, costUsd: Math.round(s.score.ours.cost * 1e4) / 1e4,
|
|
26
|
+
workflow: wf ? { title: wf.title, done: wf.steps.filter((x) => x.status === "done").length, total: wf.steps.length, completed: !!wf.complete } : null,
|
|
27
|
+
handoffRequested: !!o.handoff?.requested,
|
|
28
|
+
});
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
/** Room for one more session? Expired and idle sessions are pruned first. */
|
|
23
32
|
export function hasCapacity() {
|
|
24
33
|
const now = Date.now();
|
|
25
|
-
for (const [id, s] of sessions) { if (now - s.createdAt > MAX_AGE_MS || now - s.lastActive > IDLE_MS) drop(id); }
|
|
34
|
+
for (const [id, s] of sessions) { if (now - s.createdAt > MAX_AGE_MS || now - s.lastActive > IDLE_MS) drop(id, "expired"); }
|
|
26
35
|
return sessions.size < MAX_SESSIONS;
|
|
27
36
|
}
|
|
28
37
|
|
|
@@ -39,6 +48,7 @@ export function newSession(opts = {}) {
|
|
|
39
48
|
lastActive: Date.now(),
|
|
40
49
|
specialistKey: null, // capability for the human-specialist console; minted on handoff
|
|
41
50
|
ours: {
|
|
51
|
+
sessionId: id, // for the events this session's agents report (webhooks)
|
|
42
52
|
mode, // "flexible" | "onboarding"
|
|
43
53
|
platform, // onboarding target {id,name,url,firstWin,path,...} or null
|
|
44
54
|
screenState: null, // last structured screen state {app,page,elements,state,v}
|
|
@@ -108,8 +118,7 @@ export function revokeSpecialistKey(sess) {
|
|
|
108
118
|
export function endSession(id) {
|
|
109
119
|
const s = sessions.get(id);
|
|
110
120
|
if (!s) return false;
|
|
111
|
-
|
|
112
|
-
drop(id);
|
|
121
|
+
drop(id, "hangup");
|
|
113
122
|
return true;
|
|
114
123
|
}
|
|
115
124
|
export function getSessionBySpecialistKey(key) {
|
package/lib/webhooks.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Event webhooks: tell your own systems what happens on calls.
|
|
2
|
+
//
|
|
3
|
+
// WEBHOOK_URL one or more URLs (comma-separated) that receive every event as JSON
|
|
4
|
+
// WEBHOOK_SECRET signs each delivery: X-Ava-Signature: sha256=HMAC(secret, `${timestamp}.${body}`)
|
|
5
|
+
// SLACK_WEBHOOK_URL a Slack incoming webhook; posts when a customer asks for a person
|
|
6
|
+
// PUBLIC_URL where specialists open Ava (for the links in those messages)
|
|
7
|
+
//
|
|
8
|
+
// Events: session.started, step.completed, workflow.completed, handoff.requested,
|
|
9
|
+
// session.ended. Delivery is fire-and-forget with one retry, so a slow or broken
|
|
10
|
+
// endpoint never holds up a call.
|
|
11
|
+
|
|
12
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
const URLS = (process.env.WEBHOOK_URL || "").split(",").map((u) => u.trim()).filter(Boolean);
|
|
15
|
+
const SECRET = process.env.WEBHOOK_SECRET || "";
|
|
16
|
+
const SLACK = process.env.SLACK_WEBHOOK_URL || "";
|
|
17
|
+
const TIMEOUT_MS = 5_000, RETRY_MS = 2_000;
|
|
18
|
+
|
|
19
|
+
export const WEBHOOKS_ENABLED = URLS.length > 0 || !!SLACK;
|
|
20
|
+
|
|
21
|
+
/** The URL a specialist opens to join a call. */
|
|
22
|
+
export function specialistUrl(key) {
|
|
23
|
+
const base = (process.env.PUBLIC_URL || `http://localhost:${Number(process.env.PORT) || 5190}`).replace(/\/+$/, "");
|
|
24
|
+
return `${base}/specialist?key=${encodeURIComponent(key)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function post(url, body, headers, label) {
|
|
28
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
29
|
+
try {
|
|
30
|
+
const r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "User-Agent": "ava-engine", ...headers }, body, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
31
|
+
if (r.ok || (r.status >= 400 && r.status < 500 && r.status !== 429)) {
|
|
32
|
+
if (!r.ok) console.warn(` [${label}] endpoint refused the event (${r.status})`);
|
|
33
|
+
return r.ok;
|
|
34
|
+
}
|
|
35
|
+
} catch { /* network error or timeout: retry once */ }
|
|
36
|
+
if (attempt === 0) await new Promise((r) => setTimeout(r, RETRY_MS));
|
|
37
|
+
}
|
|
38
|
+
console.warn(` [${label}] delivery failed after a retry`); // never log the URL: Slack URLs are secrets
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Signature headers for a delivery body (exported for tests and for receivers to copy). */
|
|
43
|
+
export function sign(body, timestamp, secret = SECRET) {
|
|
44
|
+
return "sha256=" + createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function slackText(type, data) {
|
|
48
|
+
if (type !== "handoff.requested") return null;
|
|
49
|
+
const where = data.platform ? ` on ${data.platform.name}` : "";
|
|
50
|
+
const step = data.workflow?.currentStep ? `\n*Stuck on:* ${data.workflow.currentStep} (${data.workflow.done}/${data.workflow.total} steps done)` : "";
|
|
51
|
+
const said = data.lastUserMessage ? `\n*Last said:* “${data.lastUserMessage}”` : "";
|
|
52
|
+
return `:raising_hand: A customer${where} asked to talk to a person.${step}${said}\n<${data.specialistUrl}|Join the call>`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Send an event to every configured endpoint. Returns a promise tests can await; callers needn't. */
|
|
56
|
+
export function emitEvent(type, data) {
|
|
57
|
+
if (!WEBHOOKS_ENABLED) return Promise.resolve();
|
|
58
|
+
const event = { id: randomUUID(), type, createdAt: new Date().toISOString(), data };
|
|
59
|
+
const body = JSON.stringify(event);
|
|
60
|
+
const jobs = URLS.map((url) => {
|
|
61
|
+
const ts = String(Math.floor(Date.now() / 1000));
|
|
62
|
+
const headers = { "X-Ava-Event": type, "X-Ava-Delivery": event.id, "X-Ava-Timestamp": ts, ...(SECRET ? { "X-Ava-Signature": sign(body, ts) } : {}) };
|
|
63
|
+
return post(url, body, headers, "webhook");
|
|
64
|
+
});
|
|
65
|
+
const text = SLACK && slackText(type, data);
|
|
66
|
+
if (text) jobs.push(post(SLACK, JSON.stringify({ text }), {}, "slack"));
|
|
67
|
+
return Promise.allSettled(jobs);
|
|
68
|
+
}
|
package/package.json
CHANGED
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"), "
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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");
|