ava-engine 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cli/setup.mjs CHANGED
@@ -1,12 +1,16 @@
1
- // `ava-engine setup`: the guided first-run wizard (and its non-interactive twin).
1
+ // `ava-engine setup` in the terminal: the guided wizard (and its non-interactive twin).
2
+ // The browser version lives in setup-server.mjs; both save through apply.mjs.
2
3
  import * as p from "@clack/prompts";
3
- import { AVA_HOME, 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";
6
+ import { buildConfig, VOICES } from "./apply.mjs";
4
7
  import { MODEL_PACKS, installedPacks, fetchPack } from "../fetch-models.mjs";
8
+ import { loadKokoro, kokoroInstalled, KOKORO_SIZE } from "../kokoro-model.mjs";
5
9
 
6
10
  const mask = (v) => (v ? `${v.slice(0, 6)}…${v.slice(-4)}` : "");
7
11
  function bail(v) { if (p.isCancel(v)) { p.cancel("Setup cancelled. Nothing was changed."); process.exit(0); } return v; }
8
12
 
9
- /** Download model packs with a progress bar per file. */
13
+ /** Download speech model packs with a progress bar per pack. */
10
14
  export async function downloadPacks(packs) {
11
15
  for (const pack of packs) {
12
16
  const spec = MODEL_PACKS[pack];
@@ -24,18 +28,37 @@ export async function downloadPacks(packs) {
24
28
  if (bar) bar.stop(`${spec.label} ready`); else p.log.success(`${spec.label} already installed`);
25
29
  } catch (e) {
26
30
  if (bar) bar.error(`${spec.label} failed`);
27
- p.log.error(`${e.message}\nYou can retry later with: ava-engine models ${pack}`);
31
+ p.log.error(`${e.message}\nYou can retry later with: npx ava-engine models ${pack}`);
28
32
  }
29
33
  }
30
34
  }
31
35
 
36
+ /** Download the Kokoro voice now, so the first call doesn't wait for it. */
37
+ export async function downloadKokoro() {
38
+ process.env.AVA_MODELS_DIR ??= MODELS_DIR;
39
+ if (kokoroInstalled()) { p.log.success("Kokoro voice already installed"); return true; }
40
+ const bar = p.progress({ max: 100, style: "heavy" });
41
+ bar.start(`Downloading the Kokoro voice (${KOKORO_SIZE})`);
42
+ let last = 0;
43
+ try {
44
+ await loadKokoro({ onProgress: ({ pct }) => { if (pct > last) { bar.advance(pct - last, `Downloading the Kokoro voice: ${pct}%`); last = pct; } } });
45
+ bar.stop("Kokoro voice ready");
46
+ return true;
47
+ } catch (e) {
48
+ bar.error("Kokoro voice download failed");
49
+ p.log.error(`${e.message}\nAva will retry when it starts, or use the browser's voice meanwhile.`);
50
+ return false;
51
+ }
52
+ }
53
+
32
54
  /**
33
55
  * The interactive wizard. Returns { start: boolean, port }.
34
56
  * Existing settings are kept unless you change them.
35
57
  */
36
58
  export async function runSetup() {
59
+ process.env.AVA_MODELS_DIR ??= MODELS_DIR; // so "is Kokoro installed?" looks in ~/.ava-engine/models
37
60
  const prev = readConfig() || {};
38
- const cfg = { ...prev };
61
+ const answers = {};
39
62
 
40
63
  console.log("");
41
64
  p.intro(" ΛVΛ Ava Engine setup ");
@@ -43,31 +66,49 @@ export async function runSetup() {
43
66
  "A screen-aware voice agent: it joins a call, sees the tab your\n" +
44
67
  "customer shares, and talks them through it.\n\n" +
45
68
  "This takes about 2 minutes. You can change anything later with\n" +
46
- "`ava-engine setup`, or by editing " + CONFIG_FILE,
69
+ "`npx ava-engine setup`, or by editing " + CONFIG_FILE,
47
70
  "Welcome",
48
71
  );
49
72
 
50
- // 1 · how to start
73
+ // 1 · AI replies
51
74
  const mode = bail(await p.select({
52
- message: "How do you want to start?",
75
+ message: "How should Ava think?",
53
76
  options: [
54
- { value: "full", label: "Full setup", hint: "recommended: real AI replies, voice, speech recognition" },
55
- { value: "mock", label: "Just look around", hint: "no API keys; canned replies, everything else works" },
77
+ { value: "full", label: "With real AI", hint: "recommended; needs an OpenRouter key" },
78
+ { value: "mock", label: "Just look around first", hint: "no key; canned replies, everything else works" },
56
79
  ],
57
80
  }));
58
-
59
- // 2 · language models
60
81
  if (mode === "mock") {
61
- delete cfg.OPENROUTER_API_KEY;
62
- p.log.info("MOCK mode: Ava will use canned replies. Run `ava-engine setup` again any time to add a key.");
82
+ answers.openrouterKey = null;
83
+ p.log.info("MOCK mode: Ava will use canned replies. Add a key any time with `npx ava-engine setup`.");
63
84
  } else {
64
85
  let key = prev.OPENROUTER_API_KEY;
65
- if (key) {
66
- const keep = bail(await p.confirm({ message: `Keep your current OpenRouter key (${mask(key)})?`, initialValue: true }));
67
- if (!keep) key = undefined;
68
- }
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.");
69
88
  while (!key) {
70
- 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
+ }
71
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();
72
113
  const s = p.spinner(); s.start("Checking the key with OpenRouter");
73
114
  const r = await checkOpenRouterKey(entered);
@@ -89,96 +130,88 @@ export async function runSetup() {
89
130
  if (next === "mock") break;
90
131
  }
91
132
  }
92
- if (key) cfg.OPENROUTER_API_KEY = key; else delete cfg.OPENROUTER_API_KEY;
133
+ answers.openrouterKey = key || null;
134
+ }
93
135
 
94
- // 3 · voice
95
- const voice = bail(await p.select({
96
- message: "Which voice should Ava speak with?",
97
- initialValue: prev.TTS_DEFAULT || "kokoro",
98
- options: [
99
- { value: "kokoro", label: "Kokoro", hint: "free, runs on this machine; downloads ~90 MB on first start" },
100
- { value: "elevenlabs", label: "ElevenLabs", hint: "premium cloud voices, needs an ElevenLabs key" },
101
- { value: "browser", label: "The browser's voice", hint: "no setup, sounds robotic" },
102
- ],
103
- }));
104
- cfg.TTS_DEFAULT = voice;
105
- cfg.KOKORO = voice === "kokoro" ? "on" : prev.KOKORO === "on" ? "on" : "off";
106
- if (voice === "elevenlabs") {
107
- let ek = prev.ELEVENLABS_API_KEY;
108
- if (ek && !bail(await p.confirm({ message: `Keep your current ElevenLabs key (${mask(ek)})?`, initialValue: true }))) ek = undefined;
109
- while (!ek) {
110
- const entered = bail(await p.password({ message: "Paste your ElevenLabs API key (elevenlabs.io → API keys)" })).trim();
111
- const s = p.spinner(); s.start("Checking the key with ElevenLabs");
112
- const r = await checkElevenLabsKey(entered);
113
- if (r.ok || r.offline) { s.stop(r.ok ? "Key works" : `${r.error}; saved anyway`); ek = entered; }
114
- else { s.error(r.error); if (!bail(await p.confirm({ message: "Try another key?", initialValue: true }))) { cfg.TTS_DEFAULT = "kokoro"; cfg.KOKORO = "on"; break; } }
115
- }
116
- if (ek) {
117
- cfg.ELEVENLABS_API_KEY = ek;
118
- const vid = bail(await p.text({ message: "ElevenLabs voice ID (Enter for the default voice, Matilda)", placeholder: "XrExE9yKIg1WjnnlVkGX", defaultValue: prev.ELEVEN_VOICE_ID || "" }));
119
- if (vid) cfg.ELEVEN_VOICE_ID = vid.trim(); else delete cfg.ELEVEN_VOICE_ID;
136
+ // 2 · voice (free and offline by default, so offered in every mode)
137
+ answers.voice = bail(await p.select({
138
+ message: "Which voice should Ava speak with?",
139
+ initialValue: VOICES[prev.TTS_DEFAULT] ? prev.TTS_DEFAULT : "kokoro",
140
+ options: Object.entries(VOICES).map(([value, v]) => ({ value, label: v.label, hint: value === "kokoro" && kokoroInstalled() ? "free, runs on this computer (installed)" : v.hint })),
141
+ }));
142
+ if (answers.voice === "kokoro") await downloadKokoro();
143
+ if (answers.voice === "elevenlabs") {
144
+ let ek = prev.ELEVENLABS_API_KEY;
145
+ if (ek && !bail(await p.confirm({ message: `Keep your current ElevenLabs key (${mask(ek)})?`, initialValue: true }))) ek = undefined;
146
+ while (!ek) {
147
+ const entered = bail(await p.password({ message: "Paste your ElevenLabs API key (elevenlabs.io → API keys)" })).trim();
148
+ const s = p.spinner(); s.start("Checking the key with ElevenLabs");
149
+ const r = await checkElevenLabsKey(entered);
150
+ if (r.ok || r.offline) { s.stop(r.ok ? "Key works" : `${r.error}; saved anyway`); ek = entered; }
151
+ else {
152
+ s.error(r.error);
153
+ if (!bail(await p.confirm({ message: "Try another key?", initialValue: true }))) { answers.voice = "kokoro"; await downloadKokoro(); break; }
120
154
  }
121
155
  }
122
-
123
- // 4 · speech recognition models
124
- const have = installedPacks(MODELS_DIR);
125
- const packs = bail(await p.multiselect({
126
- message: "Download offline speech recognition? (space to toggle, enter to confirm)",
127
- required: false,
128
- initialValues: have.length ? have : ["english-small"],
129
- options: Object.entries(MODEL_PACKS).map(([id, m]) => ({ value: id, label: `${m.label} · ${m.size}`, hint: have.includes(id) ? "installed" : m.hint })),
130
- }));
131
- const missing = packs.filter((x) => !have.includes(x));
132
- if (missing.length) await downloadPacks(missing);
133
- else if (!packs.length) p.log.info("No models: Ava will use the browser's speech recognition (Chrome).");
156
+ if (ek) {
157
+ answers.elevenKey = ek;
158
+ answers.elevenVoice = bail(await p.text({ message: "ElevenLabs voice ID (Enter for the default voice, Matilda)", placeholder: "XrExE9yKIg1WjnnlVkGX", defaultValue: prev.ELEVEN_VOICE_ID || "" })).trim() || null;
159
+ }
134
160
  }
135
161
 
136
- // 5 · who can reach it
137
- const audience = bail(await p.select({
162
+ // 3 · speech recognition (offered in every mode)
163
+ const have = installedPacks(MODELS_DIR);
164
+ const packs = bail(await p.multiselect({
165
+ message: "Download offline speech recognition? (space to toggle, enter to confirm)",
166
+ required: false,
167
+ initialValues: have.length ? have : ["english-small"],
168
+ options: Object.entries(MODEL_PACKS).map(([id, m]) => ({ value: id, label: `${m.label} · ${m.size}`, hint: have.includes(id) ? "installed" : m.hint })),
169
+ }));
170
+ const missing = packs.filter((x) => !have.includes(x));
171
+ if (missing.length) await downloadPacks(missing);
172
+ else if (!packs.length) p.log.info("No models: Ava will use the browser's speech recognition (Chrome). Add them later with `npx ava-engine models`.");
173
+
174
+ // 4 · who can reach it
175
+ answers.audience = bail(await p.select({
138
176
  message: "Who will use this Ava?",
139
- initialValue: prev.HOST === "127.0.0.1" || !prev.ACCESS_PASSWORD ? "me" : "others",
177
+ initialValue: prev.ACCESS_PASSWORD ? "others" : "me",
140
178
  options: [
141
179
  { value: "me", label: "Just me, on this computer", hint: "safest; reachable only at localhost" },
142
180
  { value: "others", label: "My team or customers", hint: "over a network or the internet; needs a password" },
143
181
  ],
144
182
  }));
145
- if (audience === "me") {
146
- cfg.HOST = "127.0.0.1";
147
- delete cfg.ACCESS_PASSWORD; delete cfg.TRUST_PROXY;
148
- } else {
149
- delete cfg.HOST;
183
+ if (answers.audience === "others") {
150
184
  const suggested = suggestPassword();
151
185
  const how = bail(await p.select({
152
186
  message: "Set an access password (every call spends your API credit)",
153
187
  options: [
154
188
  ...(prev.ACCESS_PASSWORD ? [{ value: "keep", label: "Keep the current password" }] : []),
155
- { value: "gen", label: `Use a generated one: ${suggested}`, hint: "recommended; it's saved in the config file" },
189
+ { value: "gen", label: `Use a generated one: ${suggested}`, hint: "recommended; it's saved in the settings file" },
156
190
  { value: "own", label: "Choose my own" },
157
191
  ],
158
192
  }));
159
- if (how === "gen") cfg.ACCESS_PASSWORD = suggested;
160
- if (how === "own") cfg.ACCESS_PASSWORD = bail(await p.password({ message: "Access password (at least 10 characters)", validate: (v) => (!v || v.length < 10 ? "Use at least 10 characters" : undefined) }));
161
- const proxied = bail(await p.confirm({ message: "Will it run behind a proxy or host (Fly.io, Cloudflare, nginx, Render…)?", initialValue: prev.TRUST_PROXY === "1" }));
162
- if (proxied) cfg.TRUST_PROXY = "1"; else delete cfg.TRUST_PROXY;
193
+ answers.password = how === "keep" ? prev.ACCESS_PASSWORD : how === "gen" ? suggested
194
+ : bail(await p.password({ message: "Access password (at least 10 characters)", validate: (v) => (!v || v.length < 10 ? "Use at least 10 characters" : undefined) }));
195
+ answers.proxy = bail(await p.confirm({ message: "Will it run behind a proxy or host (Fly.io, Cloudflare, nginx, Render…)?", initialValue: prev.TRUST_PROXY === "1" }));
163
196
  }
164
197
 
165
- // 6 · port
198
+ // 5 · port
166
199
  const portStr = bail(await p.text({
167
200
  message: "Port",
168
201
  defaultValue: String(prev.PORT || DEFAULT_PORT),
169
202
  placeholder: String(prev.PORT || DEFAULT_PORT),
170
203
  validate: (v) => { if (!v) return undefined; const n = Number(v); return Number.isInteger(n) && n > 0 && n < 65536 ? undefined : "Use a number between 1 and 65535"; },
171
204
  }));
172
- cfg.PORT = String(Number(portStr) || DEFAULT_PORT);
173
- if (!(await portFree(Number(cfg.PORT), cfg.HOST))) p.log.warn(`Port ${cfg.PORT} is in use right now. Stop that program, or run \`ava-engine start --port <other>\`.`);
205
+ answers.port = Number(portStr) || DEFAULT_PORT;
174
206
 
175
- // 7 · save
176
- cfg.AVA_MODELS_DIR = MODELS_DIR;
207
+ // 6 · save
208
+ const cfg = buildConfig(prev, answers);
209
+ if (!(await portFree(Number(cfg.PORT), cfg.HOST))) p.log.warn(`Port ${cfg.PORT} is in use right now. Stop that program, or run \`npx ava-engine start --port <other>\`.`);
177
210
  writeConfig(cfg);
178
211
  p.note(
179
212
  [
180
213
  `AI replies ${cfg.OPENROUTER_API_KEY ? `OpenRouter (${mask(cfg.OPENROUTER_API_KEY)})` : "MOCK mode (canned replies)"}`,
181
- `Voice ${{ kokoro: "Kokoro (offline)", elevenlabs: "ElevenLabs", browser: "the browser's voice" }[cfg.TTS_DEFAULT] || "Kokoro (offline)"}`,
214
+ `Voice ${VOICES[cfg.TTS_DEFAULT]?.label || "Kokoro"}`,
182
215
  `Speech models ${installedPacks(MODELS_DIR).map((x) => MODEL_PACKS[x].label).join(", ") || "none (browser speech recognition)"}`,
183
216
  `Access ${cfg.HOST === "127.0.0.1" ? "this computer only" : `password protected${cfg.ACCESS_PASSWORD ? ` (${cfg.ACCESS_PASSWORD})` : ""}`}`,
184
217
  `Address http://localhost:${cfg.PORT}/app`,
@@ -209,11 +242,16 @@ export async function runSetupNonInteractive({ models = [], port } = {}) {
209
242
  cfg.AVA_MODELS_DIR = MODELS_DIR;
210
243
  writeConfig(cfg);
211
244
  for (const pack of models) {
212
- console.log(`↓ ${MODEL_PACKS[pack]?.label || pack}`);
245
+ if (pack === "kokoro") {
246
+ process.env.AVA_MODELS_DIR ??= MODELS_DIR;
247
+ console.log("↓ Kokoro voice");
248
+ if (!kokoroInstalled()) await loadKokoro();
249
+ continue;
250
+ }
251
+ if (!MODEL_PACKS[pack]) throw new Error(`unknown model "${pack}" (choose: ${Object.keys(MODEL_PACKS).join(", ")}, kokoro)`);
252
+ console.log(`↓ ${MODEL_PACKS[pack].label}`);
213
253
  await fetchPack(pack, MODELS_DIR);
214
254
  }
215
255
  console.log(`Saved ${CONFIG_FILE}${cfg.OPENROUTER_API_KEY ? "" : " (MOCK mode: no OPENROUTER_API_KEY)"}`);
216
256
  return cfg;
217
257
  }
218
-
219
- export { AVA_HOME };
@@ -0,0 +1,51 @@
1
+ // The offline Kokoro voice model: where it's cached, whether it's there, and
2
+ // loading it with download progress. Shared by the server and the setup wizard.
3
+ //
4
+ // It's cached under the models folder (~/.ava-engine/models/kokoro for the CLI),
5
+ // not inside node_modules, so it survives `npx` updates instead of downloading
6
+ // again after every new version.
7
+
8
+ import { existsSync, readdirSync } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ export const KOKORO_MODEL = process.env.KOKORO_MODEL || "onnx-community/Kokoro-82M-v1.0-ONNX";
13
+ export const KOKORO_DTYPE = process.env.KOKORO_DTYPE || "q8";
14
+ export const KOKORO_SIZE = "~90 MB";
15
+
16
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
17
+ export function kokoroCacheDir() {
18
+ return process.env.KOKORO_CACHE_DIR || join(process.env.AVA_MODELS_DIR || join(ROOT, "models"), "kokoro");
19
+ }
20
+
21
+ /** True once the model's weights are on disk (no network needed to start). */
22
+ export function kokoroInstalled(dir = kokoroCacheDir()) {
23
+ const onnx = join(dir, ...KOKORO_MODEL.split("/"), "onnx");
24
+ try { return existsSync(onnx) && readdirSync(onnx).some((f) => f.endsWith(".onnx")); } catch { return false; }
25
+ }
26
+
27
+ /**
28
+ * Load Kokoro, downloading it first if needed.
29
+ * onProgress({ loaded, total, pct }) reports the overall download.
30
+ */
31
+ export async function loadKokoro({ onProgress } = {}) {
32
+ const { env } = await import("@huggingface/transformers");
33
+ env.cacheDir = kokoroCacheDir();
34
+ const { KokoroTTS } = await import("kokoro-js");
35
+ const files = new Map(); // file → { loaded, total }
36
+ // Count only the large files (the weights). The small config/tokenizer files
37
+ // finish first and would make the bar jump to 100% and back.
38
+ const progress_callback = (e) => {
39
+ if (!onProgress || e.status !== "progress" || !(e.total > 1_000_000)) return;
40
+ files.set(e.file, { loaded: e.loaded, total: e.total });
41
+ let loaded = 0, total = 0;
42
+ for (const f of files.values()) { loaded += f.loaded; total += f.total; }
43
+ onProgress({ loaded, total, pct: Math.min(100, Math.floor((loaded / total) * 100)) });
44
+ };
45
+ // transformers.js warns about a missing content-length on every download; it's
46
+ // harmless and would break the setup wizard's progress bar, so hide just that line.
47
+ const warn = console.warn;
48
+ console.warn = (...args) => { if (!String(args[0]).includes("content-length")) warn(...args); };
49
+ try { return await KokoroTTS.from_pretrained(KOKORO_MODEL, { dtype: KOKORO_DTYPE, device: "cpu", progress_callback }); }
50
+ finally { console.warn = warn; }
51
+ }
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) };