ava-engine 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +11 -0
- package/README.md +23 -3
- package/lib/admin.mjs +284 -0
- package/lib/analytics.mjs +73 -0
- package/lib/calls.mjs +97 -0
- package/lib/cli/config.mjs +1 -0
- package/lib/cli/main.mjs +77 -4
- package/lib/cli/openrouter-oauth.mjs +87 -0
- package/lib/cli/setup-server.mjs +32 -4
- package/lib/cli/setup.mjs +27 -3
- package/lib/draft.mjs +53 -0
- package/lib/guard.mjs +73 -28
- package/lib/library.mjs +76 -0
- package/lib/links.mjs +148 -0
- package/lib/mock.mjs +88 -0
- package/lib/models.mjs +10 -4
- package/lib/openrouter.mjs +9 -1
- package/lib/ours.mjs +43 -29
- package/lib/platforms.mjs +77 -1
- package/lib/playbooks.mjs +120 -0
- package/lib/safe-fetch.mjs +79 -0
- package/lib/settings.mjs +187 -0
- package/lib/spend.mjs +62 -0
- package/lib/state.mjs +46 -7
- package/lib/stt.mjs +4 -1
- package/lib/team.mjs +191 -0
- package/lib/tts.mjs +18 -11
- package/lib/webhooks.mjs +112 -0
- package/package.json +3 -3
- package/public/app.js +86 -3
- package/public/dashboard.css +430 -0
- package/public/dashboard.html +76 -0
- package/public/dashboard.js +1096 -0
- package/public/embed.js +60 -0
- package/public/index.html +18 -9
- package/public/login.css +30 -0
- package/public/login.html +37 -0
- package/public/login.js +27 -0
- package/public/setup.css +139 -55
- package/public/setup.html +45 -18
- package/public/setup.js +43 -10
- package/public/style.css +21 -0
- package/server.mjs +164 -44
package/lib/platforms.mjs
CHANGED
|
@@ -2,8 +2,16 @@
|
|
|
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 { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { loadPlaybooks, mergePlaybooks, validatePlaybook, PLAYBOOKS_DIR } from "./playbooks.mjs";
|
|
13
|
+
|
|
14
|
+
export const BUILTIN_PLATFORMS = [
|
|
7
15
|
{
|
|
8
16
|
id: "todoist", name: "Todoist", url: "https://app.todoist.com",
|
|
9
17
|
category: "Productivity", accent: "#E44332",
|
|
@@ -194,6 +202,74 @@ export const PLATFORMS = [
|
|
|
194
202
|
},
|
|
195
203
|
];
|
|
196
204
|
|
|
205
|
+
import { onSettingsChange } from "./settings.mjs";
|
|
206
|
+
|
|
207
|
+
const builtinsHidden = () => /^(off|0|false|no)$/i.test(process.env.BUILTIN_PLAYBOOKS || "");
|
|
208
|
+
// Live bindings: reloadPlaybooks() swaps these in place. Calls already running keep
|
|
209
|
+
// the playbook they started with.
|
|
210
|
+
export let PLATFORMS = [];
|
|
211
|
+
export let CUSTOM_PLAYBOOKS = { count: 0, dir: PLAYBOOKS_DIR, problems: [], playbooks: [] };
|
|
212
|
+
|
|
213
|
+
/** Re-read your playbook files (at startup, and after the dashboard saves one). */
|
|
214
|
+
export function reloadPlaybooks({ quiet = false } = {}) {
|
|
215
|
+
const custom = loadPlaybooks();
|
|
216
|
+
if (!quiet) for (const p of custom.problems) {
|
|
217
|
+
for (const e of p.errors) console.warn(` [playbooks] ${p.file} skipped: ${e}`);
|
|
218
|
+
for (const w of p.warnings) console.warn(` [playbooks] ${p.file}: ${w}`);
|
|
219
|
+
}
|
|
220
|
+
PLATFORMS = mergePlaybooks(BUILTIN_PLATFORMS, custom.playbooks, { hideBuiltins: builtinsHidden() });
|
|
221
|
+
CUSTOM_PLAYBOOKS = { count: custom.playbooks.length, dir: PLAYBOOKS_DIR, problems: custom.problems, playbooks: custom.playbooks };
|
|
222
|
+
return CUSTOM_PLAYBOOKS;
|
|
223
|
+
}
|
|
224
|
+
reloadPlaybooks();
|
|
225
|
+
onSettingsChange((k) => { if (k.has("BUILTIN_PLAYBOOKS")) reloadPlaybooks({ quiet: true }); });
|
|
226
|
+
|
|
227
|
+
/** Every playbook for the dashboard: yours (editable), the built-ins, and files that need fixing. */
|
|
228
|
+
export function playbooksAdminView() {
|
|
229
|
+
const mine = new Set(CUSTOM_PLAYBOOKS.playbooks.map((p) => p.id));
|
|
230
|
+
const row = (p, source) => ({ ...p, source, file: p.source || null, shown: PLATFORMS.includes(p) && !p.demoOnly });
|
|
231
|
+
return {
|
|
232
|
+
dir: PLAYBOOKS_DIR.replace(homedir(), "~"), hideBuiltins: builtinsHidden(),
|
|
233
|
+
playbooks: [
|
|
234
|
+
...CUSTOM_PLAYBOOKS.playbooks.map((p) => row(p, "yours")),
|
|
235
|
+
...BUILTIN_PLATFORMS.map((p) => ({ ...row(p, "built-in"), overridden: mine.has(p.id) })),
|
|
236
|
+
],
|
|
237
|
+
problems: CUSTOM_PLAYBOOKS.problems,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Check and save one of your playbooks as <id>.json, then reload. Returns { playbook } or { errors }. */
|
|
242
|
+
export function savePlaybook(raw, { previousId } = {}) {
|
|
243
|
+
const r = validatePlaybook(raw);
|
|
244
|
+
if (r.errors) return { errors: r.errors };
|
|
245
|
+
const { id } = r.playbook;
|
|
246
|
+
const clash = CUSTOM_PLAYBOOKS.playbooks.find((p) => p.id === id && p.id !== previousId);
|
|
247
|
+
if (clash) return { errors: [`You already have a playbook with the id “${id}”.`] };
|
|
248
|
+
mkdirSync(PLAYBOOKS_DIR, { recursive: true });
|
|
249
|
+
const { knowledge, ...rest } = r.playbook;
|
|
250
|
+
const file = { ...rest, knowledge: knowledge.map((k) => ({ keywords: k.k, text: k.text })) };
|
|
251
|
+
for (const k of Object.keys(file)) if (file[k] == null || file[k] === "") delete file[k];
|
|
252
|
+
const path = join(PLAYBOOKS_DIR, `${id}.json`), tmp = `${path}.${process.pid}.tmp`;
|
|
253
|
+
writeFileSync(tmp, JSON.stringify(file, null, 2) + "\n");
|
|
254
|
+
renameSync(tmp, path);
|
|
255
|
+
if (previousId && previousId !== id) removePlaybookFile(previousId); // renamed
|
|
256
|
+
reloadPlaybooks({ quiet: true });
|
|
257
|
+
return { playbook: platformById(id) };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function removePlaybookFile(id) {
|
|
261
|
+
const p = CUSTOM_PLAYBOOKS.playbooks.find((x) => x.id === id);
|
|
262
|
+
if (!p?.source) return false;
|
|
263
|
+
rmSync(join(PLAYBOOKS_DIR, p.source), { force: true });
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
/** Delete one of your playbook files. Built-ins can't be deleted. */
|
|
267
|
+
export function deletePlaybook(id) {
|
|
268
|
+
const ok = removePlaybookFile(id);
|
|
269
|
+
if (ok) reloadPlaybooks({ quiet: true });
|
|
270
|
+
return ok;
|
|
271
|
+
}
|
|
272
|
+
|
|
197
273
|
export function platformById(id) { return PLATFORMS.find((p) => p.id === id) || null; }
|
|
198
274
|
// Trimmed shape for the onboarding picker in the client (demo-only entries hidden).
|
|
199
275
|
// Demo-only (fictional) products are included, flagged, so demo mode can select
|
|
@@ -0,0 +1,120 @@
|
|
|
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", "author", "origin", "$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
|
+
if (raw.author !== undefined && !str(raw.author, 80)) errors.push(`"author" must be a name (up to 80 characters)`);
|
|
65
|
+
// origin: where an installed copy came from ({ library, sha256, installedAt }), kept so updates can be offered
|
|
66
|
+
let origin;
|
|
67
|
+
if (raw.origin !== undefined) {
|
|
68
|
+
const o = raw.origin;
|
|
69
|
+
if (!o || typeof o !== "object" || !str(o.library, 300) || !/^[0-9a-f]{64}$/.test(String(o.sha256 || ""))) errors.push(`"origin" is set by the library installer; remove it if you edited this by hand`);
|
|
70
|
+
else origin = { library: o.library, sha256: o.sha256, installedAt: typeof o.installedAt === "string" ? o.installedAt.slice(0, 40) : undefined };
|
|
71
|
+
}
|
|
72
|
+
let knowledge = [];
|
|
73
|
+
if (raw.knowledge !== undefined) {
|
|
74
|
+
if (!Array.isArray(raw.knowledge) || raw.knowledge.length > 200) errors.push(`"knowledge" must be a list of up to 200 entries`);
|
|
75
|
+
else raw.knowledge.forEach((e, i) => {
|
|
76
|
+
const kw = Array.isArray(e?.keywords) ? e.keywords.join(" ") : e?.keywords ?? e?.k;
|
|
77
|
+
if (!str(e?.text, 1000)) errors.push(`knowledge[${i}]: "text" is required (up to 1000 characters)`);
|
|
78
|
+
else if (!str(kw, 300)) errors.push(`knowledge[${i}]: "keywords" is required: the words a question would use, e.g. "invoice export csv download"`);
|
|
79
|
+
else knowledge.push({ k: kw.toLowerCase(), text: e.text });
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
for (const k of Object.keys(raw)) if (!KNOWN.has(k)) warnings.push(`unknown field "${k}" is ignored`);
|
|
83
|
+
if (errors.length) return { errors, warnings };
|
|
84
|
+
const { name, url, category, accent, tagline, firstWin, optional, etaMin, demoOnly, author } = raw;
|
|
85
|
+
return {
|
|
86
|
+
warnings,
|
|
87
|
+
playbook: {
|
|
88
|
+
id, name, url, category: category || "Your product", accent, tagline: tagline || "", firstWin: firstWin || "the first step done",
|
|
89
|
+
path: raw.path || [], optional, journey: raw.journey, etaMin: etaMin || null, knowledge, ...(demoOnly ? { demoOnly: true } : {}),
|
|
90
|
+
...(author ? { author } : {}), ...(origin ? { origin } : {}),
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Read every *.json in `dir`. Returns { playbooks, problems: [{file, errors, warnings}] }. */
|
|
96
|
+
export function loadPlaybooks(dir = PLAYBOOKS_DIR) {
|
|
97
|
+
const playbooks = [], problems = [];
|
|
98
|
+
if (!existsSync(dir)) return { playbooks, problems, dir };
|
|
99
|
+
const seen = new Map();
|
|
100
|
+
for (const file of readdirSync(dir).filter((f) => f.endsWith(".json")).sort()) {
|
|
101
|
+
let raw;
|
|
102
|
+
try { raw = JSON.parse(readFileSync(join(dir, file), "utf8")); }
|
|
103
|
+
catch (e) { problems.push({ file, errors: [`not valid JSON: ${e.message}`], warnings: [] }); continue; }
|
|
104
|
+
const r = validatePlaybook(raw, file.replace(/\.json$/, ""));
|
|
105
|
+
if (r.playbook && seen.has(r.playbook.id)) r.errors = [`id "${r.playbook.id}" is already used by ${seen.get(r.playbook.id)}`];
|
|
106
|
+
if (r.errors?.length) { problems.push({ file, errors: r.errors, warnings: r.warnings }); continue; }
|
|
107
|
+
if (r.warnings.length) problems.push({ file, errors: [], warnings: r.warnings });
|
|
108
|
+
seen.set(r.playbook.id, file);
|
|
109
|
+
playbooks.push({ ...r.playbook, source: file });
|
|
110
|
+
}
|
|
111
|
+
return { playbooks, problems, dir };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Built-ins plus your files: yours replace a built-in with the same id. */
|
|
115
|
+
export function mergePlaybooks(builtins, custom, { hideBuiltins = false } = {}) {
|
|
116
|
+
const ids = new Set(custom.map((p) => p.id));
|
|
117
|
+
// Demo-only built-ins stay: demo mode needs them and the lobby never lists them.
|
|
118
|
+
const kept = builtins.filter((b) => !ids.has(b.id) && (!hideBuiltins || b.demoOnly));
|
|
119
|
+
return [...custom, ...kept];
|
|
120
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Fetch a public web page for "Draft with AI", and nothing else.
|
|
2
|
+
//
|
|
3
|
+
// The address comes from a dashboard user, so the server must not become a way
|
|
4
|
+
// to read private networks (cloud metadata at 169.254.169.254, localhost admin
|
|
5
|
+
// pages, the office LAN). Every connection, including each redirect, is checked
|
|
6
|
+
// at the moment it's made: the DNS answer is filtered in the socket's own lookup,
|
|
7
|
+
// so a name can't resolve to a public address for the check and a private one
|
|
8
|
+
// for the request. Small, slow-capped and text only.
|
|
9
|
+
|
|
10
|
+
import http from "node:http";
|
|
11
|
+
import https from "node:https";
|
|
12
|
+
import dns from "node:dns";
|
|
13
|
+
import net from "node:net";
|
|
14
|
+
|
|
15
|
+
const MAX_BYTES = 2 * 1024 * 1024, TIMEOUT_MS = 12_000, MAX_REDIRECTS = 3;
|
|
16
|
+
|
|
17
|
+
/** True for addresses a public fetch must never reach. */
|
|
18
|
+
export function isPrivateAddress(ip) {
|
|
19
|
+
if (net.isIPv4(ip)) {
|
|
20
|
+
const [a, b] = ip.split(".").map(Number);
|
|
21
|
+
return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254)
|
|
22
|
+
|| (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 192 && b === 0) || (a === 198 && (b === 18 || b === 19)) || a >= 224;
|
|
23
|
+
}
|
|
24
|
+
const v = ip.toLowerCase();
|
|
25
|
+
if (v.startsWith("::ffff:")) return isPrivateAddress(v.slice(7));
|
|
26
|
+
return v === "::" || v === "::1" || /^f[cd]/.test(v) || /^fe[89ab]/.test(v) || v.startsWith("ff");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function publicLookup(host, opts, cb) {
|
|
30
|
+
dns.lookup(host, { all: true }, (err, list) => {
|
|
31
|
+
if (err) return cb(err);
|
|
32
|
+
const ok = list.find((a) => !isPrivateAddress(a.address));
|
|
33
|
+
if (!ok || list.some((a) => isPrivateAddress(a.address))) return cb(Object.assign(new Error("That address points to a private network."), { code: "EPRIVATE" }));
|
|
34
|
+
opts?.all ? cb(null, [ok]) : cb(null, ok.address, ok.family);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function once(url) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const lib = url.protocol === "https:" ? https : http;
|
|
41
|
+
if (net.isIP(url.hostname.replace(/^\[|\]$/g, "")) && isPrivateAddress(url.hostname.replace(/^\[|\]$/g, ""))) return reject(new Error("That address points to a private network."));
|
|
42
|
+
const req = lib.get(url, { lookup: publicLookup, timeout: TIMEOUT_MS, headers: { "User-Agent": "ava-engine (playbook drafting)", Accept: "text/html,text/plain,text/markdown;q=0.9,*/*;q=0.1" } }, (res) => {
|
|
43
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); return resolve({ redirect: new URL(res.headers.location, url) }); }
|
|
44
|
+
if (res.statusCode !== 200) { res.resume(); return reject(new Error(`The page answered ${res.statusCode}.`)); }
|
|
45
|
+
const type = String(res.headers["content-type"] || "");
|
|
46
|
+
if (!/text\/|application\/(xhtml|json|xml)/.test(type)) { res.resume(); return reject(new Error("That address isn't a web page or text.")); }
|
|
47
|
+
let size = 0; const chunks = [];
|
|
48
|
+
res.on("data", (c) => { size += c.length; if (size > MAX_BYTES) { req.destroy(new Error("That page is too large (over 2 MB).")); } else chunks.push(c); });
|
|
49
|
+
res.on("end", () => resolve({ body: Buffer.concat(chunks).toString("utf8"), type }));
|
|
50
|
+
res.on("error", reject);
|
|
51
|
+
});
|
|
52
|
+
req.on("timeout", () => req.destroy(new Error("The page took too long to answer.")));
|
|
53
|
+
req.on("error", reject);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** GET a public http(s) page. Returns { text, type, url }. */
|
|
58
|
+
export async function fetchPublic(address) {
|
|
59
|
+
let url;
|
|
60
|
+
try { url = new URL(address); } catch { throw new Error("That isn't a valid web address."); }
|
|
61
|
+
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
|
62
|
+
if (!/^https?:$/.test(url.protocol)) throw new Error("Only http:// and https:// addresses can be read.");
|
|
63
|
+
const r = await once(url);
|
|
64
|
+
if (r.redirect) { url = r.redirect; continue; }
|
|
65
|
+
return { text: r.body, type: r.type, url: url.href };
|
|
66
|
+
}
|
|
67
|
+
throw new Error("That page redirects too many times.");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Readable text from HTML: headings and paragraphs, without scripts, styles and navigation. */
|
|
71
|
+
export function htmlToText(html) {
|
|
72
|
+
return String(html)
|
|
73
|
+
.replace(/<(script|style|noscript|svg|nav|footer|header|form|iframe)\b[\s\S]*?<\/\1>/gi, " ")
|
|
74
|
+
.replace(/<(h[1-6])\b[^>]*>/gi, "\n\n## ").replace(/<(br|\/p|\/li|\/h[1-6]|\/tr|\/div)\b[^>]*>/gi, "\n").replace(/<li\b[^>]*>/gi, "\n- ")
|
|
75
|
+
.replace(/<[^>]+>/g, " ")
|
|
76
|
+
.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'|'/g, "'")
|
|
77
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
|
|
78
|
+
.replace(/[ \t\f\r]+/g, " ").replace(/ *\n */g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
79
|
+
}
|
package/lib/settings.mjs
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Settings you can change from the dashboard, applied without a restart.
|
|
2
|
+
//
|
|
3
|
+
// Ava's settings are environment variables, saved in a .env file: the CLI's
|
|
4
|
+
// ~/.ava-engine/.env (AVA_CONFIG_FILE), or ./.env when run from source. Saving
|
|
5
|
+
// here edits that file in place (other lines and comments are kept), updates
|
|
6
|
+
// the running process, and tells every module that caches a value to reload.
|
|
7
|
+
//
|
|
8
|
+
// A variable set by the host itself (Fly secrets, Docker -e, the shell) wins over
|
|
9
|
+
// the file on the next start, so the dashboard shows those as read-only.
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, renameSync, writeFileSync, chmodSync, mkdirSync } from "node:fs";
|
|
12
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
|
+
|
|
14
|
+
export const CONFIG_PATH = process.env.AVA_CONFIG_FILE || resolve(".env");
|
|
15
|
+
|
|
16
|
+
const SLUG = /^[\w.-]+\/[\w.:-]+$/;
|
|
17
|
+
const num = (min, max, int = false) => ({ type: "number", min, max, int });
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Every editable setting. `secret`: never sent to the browser. `restart`: saved,
|
|
21
|
+
* but only takes effect after Ava restarts. `section` groups them on the page.
|
|
22
|
+
*/
|
|
23
|
+
export const SCHEMA = [
|
|
24
|
+
// AI
|
|
25
|
+
{ key: "OPENROUTER_API_KEY", section: "ai", label: "OpenRouter API key", type: "secret", min: 20, hint: "Empty = demo mode with canned replies." },
|
|
26
|
+
{ key: "MODEL_TALKER", section: "ai", label: "Talker model", type: "slug", def: "meta-llama/llama-3.3-70b-instruct", hint: "Writes the spoken reply. Latency matters most here." },
|
|
27
|
+
{ key: "MODEL_PERCEPTION", section: "ai", label: "Screen-reading model", type: "slug", def: "google/gemini-2.5-flash", hint: "Must accept images." },
|
|
28
|
+
{ key: "MODEL_INTENT", section: "ai", label: "Intent model", type: "slug", def: "google/gemini-2.5-flash-lite", hint: "Classifies turns and checks workflow steps." },
|
|
29
|
+
{ key: "MODEL_PLANNER", section: "ai", label: "Planner model", type: "slug", def: "anthropic/claude-sonnet-4.5", hint: "Keeps the goal and plan. Runs after the reply." },
|
|
30
|
+
{ key: "TALKER_PROVIDER", section: "ai", label: "Talker provider", type: "provider", def: "groq", hint: "An OpenRouter provider to prefer, or auto." },
|
|
31
|
+
{ key: "PERCEPTION_PROVIDER", section: "ai", label: "Screen-reading provider", type: "provider", def: "", hint: "Optional. Empty or auto lets OpenRouter choose." },
|
|
32
|
+
// voice
|
|
33
|
+
{ key: "TTS_DEFAULT", section: "voice", label: "Ava's voice", type: "enum", options: ["kokoro", "elevenlabs", "hume", "browser"], def: "kokoro" },
|
|
34
|
+
{ key: "ELEVENLABS_API_KEY", section: "voice", label: "ElevenLabs API key", type: "secret", min: 20 },
|
|
35
|
+
{ key: "ELEVEN_VOICE_ID", section: "voice", label: "ElevenLabs voice ID", type: "text", max: 64, def: "XrExE9yKIg1WjnnlVkGX" },
|
|
36
|
+
{ key: "HUME_API_KEY", section: "voice", label: "Hume API key", type: "secret", min: 20 },
|
|
37
|
+
{ key: "KOKORO_VOICE", section: "voice", label: "Kokoro voice", type: "text", max: 32, def: "af_heart", hint: "e.g. af_heart, af_bella, am_michael, bf_emma." },
|
|
38
|
+
{ key: "KOKORO", section: "voice", label: "Offline Kokoro voice", type: "enum", options: ["on", "off"], def: "on", restart: true },
|
|
39
|
+
{ key: "DEEPGRAM_API_KEY", section: "voice", label: "Deepgram API key", type: "secret", min: 20, hint: "Cloud speech-to-text, used only without offline models." },
|
|
40
|
+
// access
|
|
41
|
+
{ key: "ACCESS_PASSWORD", section: "access", label: "Access password", type: "secret", min: 10, hint: "Needed for the dashboard and test page from other computers. Share links don't need it." },
|
|
42
|
+
{ key: "PUBLIC_URL", section: "access", label: "Public address", type: "url", def: "", hint: "Where customers reach Ava, e.g. https://ava.example.com. Used in share links and alerts." },
|
|
43
|
+
{ key: "HOST", section: "access", label: "Listen on", type: "enum", options: ["127.0.0.1", "0.0.0.0"], def: "0.0.0.0", restart: true, hint: "127.0.0.1 = this computer only." },
|
|
44
|
+
{ key: "PORT", section: "access", label: "Port", ...num(1, 65535, true), def: "5190", restart: true },
|
|
45
|
+
{ key: "TRUST_PROXY", section: "access", label: "Behind a proxy", type: "enum", options: ["1", "0"], def: "0", restart: true, hint: "1 when Fly, Cloudflare or nginx sits in front." },
|
|
46
|
+
// limits
|
|
47
|
+
{ key: "SESSION_BUDGET_USD", section: "limits", label: "Spend cap per call (USD)", ...num(0.01, 1000), def: "2" },
|
|
48
|
+
{ key: "DAILY_BUDGET_USD", section: "limits", label: "Spend cap per day (USD)", ...num(0, 1_000_000), def: "", hint: "All calls together. Empty or 0 = no cap." },
|
|
49
|
+
{ key: "MAX_SESSIONS", section: "limits", label: "Calls at once", ...num(1, 10_000, true), def: "50" },
|
|
50
|
+
{ key: "RATE_SESSIONS_PER_MIN", section: "limits", label: "New calls per visitor per minute", ...num(1, 10_000, true), def: "10" },
|
|
51
|
+
{ key: "RATE_API_PER_MIN", section: "limits", label: "Requests per visitor per minute", ...num(10, 100_000, true), def: "240" },
|
|
52
|
+
// events
|
|
53
|
+
{ key: "WEBHOOK_URL", section: "events", label: "Webhook URLs", type: "urls", secret: true, hint: "Comma-separated. Every call event is POSTed here as JSON." },
|
|
54
|
+
{ key: "WEBHOOK_SECRET", section: "events", label: "Webhook signing secret", type: "secret", min: 16 },
|
|
55
|
+
{ key: "SLACK_WEBHOOK_URL", section: "events", label: "Slack incoming webhook", type: "url", secret: true, hint: "Posts when a customer asks for a person." },
|
|
56
|
+
{ key: "SLACK_BOT_TOKEN", section: "events", label: "Slack bot token", type: "secret", min: 20, hint: "Optional, instead of the webhook: one thread per handoff with updates. Needs a bot with chat:write in the channel." },
|
|
57
|
+
{ key: "SLACK_CHANNEL", section: "events", label: "Slack channel", type: "text", max: 80, hint: "The channel ID for the bot, e.g. C0123ABCD." },
|
|
58
|
+
// data
|
|
59
|
+
{ key: "TRANSCRIPTS", section: "data", label: "Keep call transcripts", type: "enum", options: ["off", "on"], def: "off", hint: "Off: only numbers are kept. On: what was said is saved for the call detail page." },
|
|
60
|
+
{ key: "TRANSCRIPT_DAYS", section: "data", label: "Delete transcripts after (days)", ...num(1, 3650, true), def: "30" },
|
|
61
|
+
// playbooks
|
|
62
|
+
{ key: "PLAYBOOK_LIBRARY_URL", section: "playbooks", label: "Playbook library", type: "url", def: "https://raw.githubusercontent.com/Mr-vero/ava-playbooks/main", hint: "Where Playbooks → Library comes from. Point it at your own fork to curate playbooks for your team." },
|
|
63
|
+
{ key: "BUILTIN_PLAYBOOKS", section: "playbooks", label: "Sample products", type: "enum", options: ["on", "off"], def: "on", hint: "Off hides the built-in samples from the test page." },
|
|
64
|
+
];
|
|
65
|
+
const BY_KEY = new Map(SCHEMA.map((s) => [s.key, s]));
|
|
66
|
+
const isSecret = (s) => s.type === "secret" || s.secret;
|
|
67
|
+
|
|
68
|
+
// ── the .env file ──
|
|
69
|
+
function parseEnv(text) {
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const line of text.split(/\r?\n/)) {
|
|
72
|
+
const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
|
73
|
+
if (!m) continue;
|
|
74
|
+
let v = m[2];
|
|
75
|
+
if (/^"(.*)"$/.test(v)) v = v.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
76
|
+
else if (/^'(.*)'$/.test(v)) v = v.slice(1, -1);
|
|
77
|
+
else v = v.replace(/\s+#.*$/, "");
|
|
78
|
+
out[m[1]] = v;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
function readFile() { try { return parseEnv(readFileSync(CONFIG_PATH, "utf8")); } catch { return {}; } }
|
|
83
|
+
/** A value as it must appear in .env, readable by both Node's --env-file and the CLI. */
|
|
84
|
+
function envValue(v) {
|
|
85
|
+
if (/^[A-Za-z0-9_@%+=:,./-]*$/.test(v)) return v;
|
|
86
|
+
if (!v.includes("'")) return `'${v}'`;
|
|
87
|
+
if (!v.includes('"') && !v.includes("\\")) return `"${v}"`;
|
|
88
|
+
throw new Error("Values can't contain both kinds of quotes.");
|
|
89
|
+
}
|
|
90
|
+
function writeFile(patch) {
|
|
91
|
+
const lines = existsSync(CONFIG_PATH) ? readFileSync(CONFIG_PATH, "utf8").split(/\r?\n/) : ["# Ava settings. Edit here or in the dashboard (Settings)."];
|
|
92
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
93
|
+
const re = new RegExp(`^\\s*(?:export\\s+)?${k}\\s*=`);
|
|
94
|
+
const i = lines.findIndex((l) => re.test(l));
|
|
95
|
+
if (v === "") { if (i >= 0) lines.splice(i, 1); continue; }
|
|
96
|
+
const line = `${k}=${envValue(v)}`;
|
|
97
|
+
if (i >= 0) lines[i] = line; else lines.splice(lines.length && lines.at(-1) === "" ? lines.length - 1 : lines.length, 0, line);
|
|
98
|
+
}
|
|
99
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
100
|
+
const tmp = join(dirname(CONFIG_PATH), `.env.${process.pid}.tmp`);
|
|
101
|
+
writeFileSync(tmp, lines.join("\n").replace(/\n*$/, "\n"), { mode: 0o600 });
|
|
102
|
+
renameSync(tmp, CONFIG_PATH);
|
|
103
|
+
try { chmodSync(CONFIG_PATH, 0o600); } catch {}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Variables the host set itself (not from our file) win on the next start: read-only here.
|
|
107
|
+
const fileAtStart = readFile();
|
|
108
|
+
const hostSet = new Set(SCHEMA.map((s) => s.key).filter((k) => process.env[k] !== undefined && process.env[k] !== fileAtStart[k]));
|
|
109
|
+
|
|
110
|
+
// ── validation ──
|
|
111
|
+
function check(s, raw) {
|
|
112
|
+
const v = String(raw ?? "").trim();
|
|
113
|
+
if (v === "") return { value: "" }; // empty = back to the default / turn off
|
|
114
|
+
if (/[\r\n\0]/.test(v)) return { error: `${s.label}: must be one line.` };
|
|
115
|
+
switch (s.type) {
|
|
116
|
+
case "secret": return v.length < (s.min || 1) ? { error: `${s.label}: at least ${s.min} characters.` } : v.length > 500 ? { error: `${s.label}: too long.` } : { value: v };
|
|
117
|
+
case "slug": return SLUG.test(v) ? { value: v } : { error: `${s.label}: use an OpenRouter model id like provider/model-name.` };
|
|
118
|
+
case "provider": return /^[a-z0-9][a-z0-9_.-]{0,40}$/i.test(v) ? { value: v } : { error: `${s.label}: a provider name like groq, or auto.` };
|
|
119
|
+
case "enum": return s.options.includes(v) ? { value: v } : { error: `${s.label}: choose one of ${s.options.join(", ")}.` };
|
|
120
|
+
case "text": return v.length <= (s.max || 200) && /^[\w.: -]+$/.test(v) ? { value: v } : { error: `${s.label}: letters, digits, dashes and dots only.` };
|
|
121
|
+
case "url": try { const u = new URL(v); return /^https?:$/.test(u.protocol) ? { value: v.replace(/\/+$/, "") } : { error: `${s.label}: must start with http:// or https://.` }; } catch { return { error: `${s.label}: isn't a valid web address.` }; }
|
|
122
|
+
case "urls": {
|
|
123
|
+
const list = v.split(",").map((x) => x.trim()).filter(Boolean);
|
|
124
|
+
for (const u of list) { try { if (!/^https?:$/.test(new URL(u).protocol)) throw 0; } catch { return { error: `${s.label}: “${u}” isn't a valid http(s) address.` }; } }
|
|
125
|
+
return { value: list.join(",") };
|
|
126
|
+
}
|
|
127
|
+
case "number": {
|
|
128
|
+
const n = Number(v);
|
|
129
|
+
if (!Number.isFinite(n) || n < s.min || n > s.max || (s.int && !Number.isInteger(n))) return { error: `${s.label}: ${s.int ? "a whole number" : "a number"} from ${s.min} to ${s.max}.` };
|
|
130
|
+
return { value: String(n) };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { error: `${s.label}: unsupported.` };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── what the dashboard sees ──
|
|
137
|
+
function mask(k, v) {
|
|
138
|
+
if (!v) return "";
|
|
139
|
+
if (k.endsWith("_URL")) return v.split(",").map((u) => { try { return `${new URL(u).host}/…`; } catch { return "…"; } }).join(", ");
|
|
140
|
+
return v.length > 12 ? `${v.slice(0, 6)}…${v.slice(-4)}` : "••••••";
|
|
141
|
+
}
|
|
142
|
+
/** Every setting, with secrets masked, for the Settings page. */
|
|
143
|
+
export function settingsView() {
|
|
144
|
+
return {
|
|
145
|
+
file: CONFIG_PATH,
|
|
146
|
+
settings: SCHEMA.map((s) => {
|
|
147
|
+
const v = process.env[s.key] ?? "";
|
|
148
|
+
return {
|
|
149
|
+
key: s.key, section: s.section, label: s.label, type: s.type, options: s.options, hint: s.hint, min: s.min, max: s.max,
|
|
150
|
+
default: s.def ?? "", secret: isSecret(s), restart: !!s.restart, set: v !== "",
|
|
151
|
+
value: isSecret(s) ? mask(s.key, v) : v,
|
|
152
|
+
locked: hostSet.has(s.key), pending: !!s.restart && pendingRestart.has(s.key),
|
|
153
|
+
};
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── saving ──
|
|
159
|
+
const listeners = [];
|
|
160
|
+
const pendingRestart = new Set();
|
|
161
|
+
/** Run fn(changedKeys) after every save, so cached values reload. */
|
|
162
|
+
export function onSettingsChange(fn) { listeners.push(fn); }
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Save changes: { KEY: "value" } ("" removes it). Secrets are only changed when
|
|
166
|
+
* sent. Returns { changed, restart } or { errors }.
|
|
167
|
+
*/
|
|
168
|
+
export function updateSettings(patch) {
|
|
169
|
+
const errors = [], clean = {};
|
|
170
|
+
for (const [k, raw] of Object.entries(patch || {})) {
|
|
171
|
+
const s = BY_KEY.get(k);
|
|
172
|
+
if (!s) { errors.push(`Unknown setting ${k}.`); continue; }
|
|
173
|
+
if (hostSet.has(k)) { errors.push(`${s.label} is set by your host's environment; change it there.`); continue; }
|
|
174
|
+
const r = check(s, raw);
|
|
175
|
+
if (r.error) errors.push(r.error); else if ((process.env[k] ?? "") !== r.value) clean[k] = r.value;
|
|
176
|
+
}
|
|
177
|
+
if (errors.length) return { errors };
|
|
178
|
+
const keys = Object.keys(clean);
|
|
179
|
+
if (!keys.length) return { changed: [], restart: [] };
|
|
180
|
+
writeFile(clean);
|
|
181
|
+
for (const k of keys) { if (clean[k] === "") delete process.env[k]; else process.env[k] = clean[k]; }
|
|
182
|
+
const restart = keys.filter((k) => BY_KEY.get(k).restart);
|
|
183
|
+
for (const k of restart) pendingRestart.add(k);
|
|
184
|
+
for (const fn of listeners) { try { fn(new Set(keys)); } catch (e) { console.error(" [settings] reload failed:", e.message); } }
|
|
185
|
+
console.log(` [settings] updated ${keys.join(", ")}${restart.length ? " (restart to apply " + restart.join(", ") + ")" : ""}`);
|
|
186
|
+
return { changed: keys, restart };
|
|
187
|
+
}
|
package/lib/spend.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
import { onSettingsChange } from "./settings.mjs";
|
|
17
|
+
|
|
18
|
+
export let DAILY_BUDGET_USD = Math.max(0, Number(process.env.DAILY_BUDGET_USD) || 0);
|
|
19
|
+
onSettingsChange((k) => { if (k.has("DAILY_BUDGET_USD")) DAILY_BUDGET_USD = Math.max(0, Number(process.env.DAILY_BUDGET_USD) || 0); });
|
|
20
|
+
const FILE = process.env.SPEND_FILE || join(process.env.AVA_HOME || join(homedir(), ".ava-engine"), "spend.json");
|
|
21
|
+
|
|
22
|
+
const today = () => new Date().toISOString().slice(0, 10);
|
|
23
|
+
let ledger = load();
|
|
24
|
+
|
|
25
|
+
function load() {
|
|
26
|
+
if (!DAILY_BUDGET_USD) return { day: today(), usd: 0 };
|
|
27
|
+
try {
|
|
28
|
+
const j = JSON.parse(readFileSync(FILE, "utf8"));
|
|
29
|
+
if (typeof j.day === "string" && Number.isFinite(j.usd) && j.usd >= 0) return { day: j.day, usd: j.usd };
|
|
30
|
+
} catch { /* first run or unreadable: start from zero */ }
|
|
31
|
+
return { day: today(), usd: 0 };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function current() {
|
|
35
|
+
const d = today();
|
|
36
|
+
if (ledger.day !== d) ledger = { day: d, usd: 0 };
|
|
37
|
+
return ledger;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Add the cost of a finished call to today's total. */
|
|
41
|
+
export function recordSpend(usd) {
|
|
42
|
+
if (!(usd > 0)) return;
|
|
43
|
+
const l = current();
|
|
44
|
+
l.usd += usd;
|
|
45
|
+
if (!DAILY_BUDGET_USD) return;
|
|
46
|
+
try {
|
|
47
|
+
if (!existsSync(dirname(FILE))) mkdirSync(dirname(FILE), { recursive: true });
|
|
48
|
+
writeFileSync(FILE, JSON.stringify(l));
|
|
49
|
+
} catch (e) { console.warn(" [spend] couldn't save the daily total:", e.message); }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** True once today's spend has reached DAILY_BUDGET_USD. */
|
|
53
|
+
export function dailyCapReached() {
|
|
54
|
+
return DAILY_BUDGET_USD > 0 && current().usd >= DAILY_BUDGET_USD;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Today's total, for logs and tests. */
|
|
58
|
+
export function spentToday() { return current().usd; }
|
|
59
|
+
|
|
60
|
+
export class DailyCapError extends Error {
|
|
61
|
+
constructor() { super("daily spending cap reached"); this.status = 402; this.code = "daily_cap"; }
|
|
62
|
+
}
|
package/lib/state.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
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";
|
|
5
|
+
import { recordCall, saveTranscript } from "./calls.mjs";
|
|
4
6
|
|
|
5
7
|
const sessions = new Map();
|
|
6
8
|
const byKey = new Map(); // specialist key → session id (see mintSpecialistKey)
|
|
@@ -9,21 +11,56 @@ function emptyScore() {
|
|
|
9
11
|
return { turns: 0, promptTokens: 0, completionTokens: 0, cost: 0, latencies: [], perception: { captured: 0, analysed: 0 } };
|
|
10
12
|
}
|
|
11
13
|
|
|
12
|
-
const
|
|
14
|
+
const maxSessions = () => Number(process.env.MAX_SESSIONS) || 50; // read live: editable in Settings
|
|
13
15
|
const MAX_AGE_MS = 6 * 3600 * 1000;
|
|
14
16
|
const IDLE_MS = 30 * 60 * 1000;
|
|
15
17
|
|
|
16
|
-
function drop(id) {
|
|
18
|
+
function drop(id, reason) {
|
|
17
19
|
const s = sessions.get(id);
|
|
18
|
-
if (s
|
|
20
|
+
if (!s) return;
|
|
21
|
+
if (s.specialistKey) revokeSpecialistKey(s);
|
|
19
22
|
sessions.delete(id);
|
|
23
|
+
const c = callSummary(s);
|
|
24
|
+
emitEvent("session.ended", { sessionId: id, reason, ...c });
|
|
25
|
+
const wf = s.ours.workflow;
|
|
26
|
+
const transcript = saveTranscript(id, {
|
|
27
|
+
lines: (s.ours.bridge?.log || []).map((l) => ({ role: l.role, text: l.text, at: new Date(l.at).toISOString() })),
|
|
28
|
+
recap: s.recap || null,
|
|
29
|
+
});
|
|
30
|
+
recordCall({
|
|
31
|
+
id, reason, startedAt: new Date(s.createdAt).toISOString(), endedAt: new Date().toISOString(), ...c,
|
|
32
|
+
steps: wf ? wf.steps.map((x) => ({ title: x.title, done: x.status === "done" })) : null, // product data, not what was said
|
|
33
|
+
transcript,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The numbers about a call: what the dashboard shows and webhooks send. No transcript. */
|
|
38
|
+
function callSummary(s) {
|
|
39
|
+
const o = s.ours, wf = o.workflow;
|
|
40
|
+
return {
|
|
41
|
+
mode: o.mode, platform: o.platform ? { id: o.platform.id, name: o.platform.name } : null,
|
|
42
|
+
link: s.link ? { id: s.link.id, name: s.link.name } : null,
|
|
43
|
+
durationSec: Math.round((Date.now() - s.createdAt) / 1000), turns: s.score.ours.turns, costUsd: Math.round(s.score.ours.cost * 1e4) / 1e4,
|
|
44
|
+
workflow: wf ? { title: wf.title, done: wf.steps.filter((x) => x.status === "done").length, total: wf.steps.length, completed: !!wf.complete } : null,
|
|
45
|
+
handoffRequested: !!o.handoff?.requested,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Calls happening now, for the dashboard. */
|
|
50
|
+
export function liveSessions() {
|
|
51
|
+
return [...sessions.values()].map((s) => ({
|
|
52
|
+
id: s.id, startedAt: new Date(s.createdAt).toISOString(), lastActiveAt: new Date(s.lastActive).toISOString(),
|
|
53
|
+
...callSummary(s),
|
|
54
|
+
currentStep: s.ours.workflow ? s.ours.workflow.steps[s.ours.workflow.current]?.title || null : null,
|
|
55
|
+
handoff: s.ours.handoff?.requested ? { state: s.ours.handoff.state, since: s.ours.handoff.at ? new Date(s.ours.handoff.at).toISOString() : null, specialistKey: s.specialistKey, pickedUpBy: s.ours.handoff.pickedUpBy || null } : null,
|
|
56
|
+
})).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
20
57
|
}
|
|
21
58
|
|
|
22
59
|
/** Room for one more session? Expired and idle sessions are pruned first. */
|
|
23
60
|
export function hasCapacity() {
|
|
24
61
|
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); }
|
|
26
|
-
return sessions.size <
|
|
62
|
+
for (const [id, s] of sessions) { if (now - s.createdAt > MAX_AGE_MS || now - s.lastActive > IDLE_MS) drop(id, "expired"); }
|
|
63
|
+
return sessions.size < maxSessions();
|
|
27
64
|
}
|
|
28
65
|
|
|
29
66
|
export function newSession(opts = {}) {
|
|
@@ -38,7 +75,10 @@ export function newSession(opts = {}) {
|
|
|
38
75
|
createdAt: Date.now(),
|
|
39
76
|
lastActive: Date.now(),
|
|
40
77
|
specialistKey: null, // capability for the human-specialist console; minted on handoff
|
|
78
|
+
link: opts.link ? { id: opts.link.id, name: opts.link.name } : null, // the share link this call came from
|
|
41
79
|
ours: {
|
|
80
|
+
sessionId: id, // for the events this session's agents report (webhooks)
|
|
81
|
+
customer: !!opts.link, // a customer on a share link (not the operator testing)
|
|
42
82
|
mode, // "flexible" | "onboarding"
|
|
43
83
|
platform, // onboarding target {id,name,url,firstWin,path,...} or null
|
|
44
84
|
screenState: null, // last structured screen state {app,page,elements,state,v}
|
|
@@ -108,8 +148,7 @@ export function revokeSpecialistKey(sess) {
|
|
|
108
148
|
export function endSession(id) {
|
|
109
149
|
const s = sessions.get(id);
|
|
110
150
|
if (!s) return false;
|
|
111
|
-
|
|
112
|
-
drop(id);
|
|
151
|
+
drop(id, "hangup");
|
|
113
152
|
return true;
|
|
114
153
|
}
|
|
115
154
|
export function getSessionBySpecialistKey(key) {
|
package/lib/stt.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// Deepgram speech-to-text (nova-3): the cloud fallback that turns a recorded
|
|
2
2
|
// utterance into text when the offline sherpa models aren't available.
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
import { onSettingsChange } from "./settings.mjs";
|
|
5
|
+
|
|
6
|
+
export let STT_ENABLED = !!process.env.DEEPGRAM_API_KEY;
|
|
7
|
+
onSettingsChange((k) => { if (k.has("DEEPGRAM_API_KEY")) STT_ENABLED = !!process.env.DEEPGRAM_API_KEY; });
|
|
5
8
|
const MODEL = process.env.DEEPGRAM_MODEL || "nova-3";
|
|
6
9
|
|
|
7
10
|
// buffer: raw audio bytes; contentType e.g. "audio/webm" or "audio/mpeg".
|