ava-engine 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +11 -0
- package/README.md +14 -10
- package/lib/asr.mjs +5 -1
- package/lib/cli/apply.mjs +58 -0
- package/lib/cli/main.mjs +77 -9
- package/lib/cli/openrouter-oauth.mjs +87 -0
- package/lib/cli/setup-server.mjs +222 -0
- package/lib/cli/setup.mjs +118 -80
- package/lib/kokoro-model.mjs +51 -0
- package/lib/mock.mjs +78 -0
- package/lib/openrouter.mjs +5 -0
- package/lib/ours.mjs +40 -28
- package/lib/platforms.mjs +15 -1
- package/lib/playbooks.mjs +111 -0
- package/lib/spend.mjs +59 -0
- package/lib/state.mjs +14 -5
- package/lib/tts.mjs +4 -4
- package/lib/webhooks.mjs +68 -0
- package/package.json +10 -2
- package/public/app.js +2 -0
- package/public/index.html +2 -0
- package/public/setup.css +97 -0
- package/public/setup.html +135 -0
- package/public/setup.js +289 -0
- package/public/style.css +4 -0
- package/server.mjs +24 -7
package/lib/cli/setup.mjs
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
// `ava-engine setup
|
|
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 {
|
|
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
|
|
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
|
|
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 ·
|
|
73
|
+
// 1 · AI replies
|
|
51
74
|
const mode = bail(await p.select({
|
|
52
|
-
message: "How
|
|
75
|
+
message: "How should Ava think?",
|
|
53
76
|
options: [
|
|
54
|
-
{ value: "full", label: "
|
|
55
|
-
{ value: "mock", label: "Just look around", hint: "no
|
|
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
|
-
|
|
62
|
-
p.log.info("MOCK mode: Ava will use canned replies.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
+
answers.openrouterKey = key || null;
|
|
134
|
+
}
|
|
93
135
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}));
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
//
|
|
137
|
-
const
|
|
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.
|
|
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 === "
|
|
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
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
176
|
-
cfg
|
|
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 ${
|
|
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
|
-
|
|
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
|
+
}
|
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) };
|