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.
@@ -0,0 +1,76 @@
1
+ // The playbook library: ready-made playbooks from a public repo
2
+ // (default https://github.com/Mr-vero/ava-playbooks), installed as your own files.
3
+ //
4
+ // The library's index.json lists each playbook with the sha256 of its file. A
5
+ // download must match that hash and pass the same checks as any playbook file
6
+ // before it's saved. Installed copies remember where they came from (origin), so
7
+ // the dashboard can offer an update when the library's version changes.
8
+
9
+ import { createHash } from "node:crypto";
10
+ import { validatePlaybook } from "./playbooks.mjs";
11
+ import { CUSTOM_PLAYBOOKS, savePlaybook } from "./platforms.mjs";
12
+
13
+ export const DEFAULT_LIBRARY = "https://raw.githubusercontent.com/Mr-vero/ava-playbooks/main";
14
+ const base = () => (process.env.PLAYBOOK_LIBRARY_URL || DEFAULT_LIBRARY).replace(/\/+$/, "");
15
+ const FILE = /^playbooks\/[a-z0-9][a-z0-9-]{0,39}\.json$/;
16
+ const MAX_INDEX = 512 * 1024, MAX_FILE = 256 * 1024, TTL_MS = 10 * 60_000;
17
+
18
+ async function get(url, max) {
19
+ const r = await fetch(url, { signal: AbortSignal.timeout(10_000), headers: { "User-Agent": "ava-engine" } });
20
+ if (!r.ok) throw new Error(`The library answered ${r.status}.`);
21
+ const buf = Buffer.from(await r.arrayBuffer());
22
+ if (buf.length > max) throw new Error("A library file is too large.");
23
+ return buf;
24
+ }
25
+
26
+ let cache = null; // { source, at, playbooks }
27
+ /** The library's list. Cached for 10 minutes; `fresh` skips the cache. */
28
+ export async function libraryIndex({ fresh = false } = {}) {
29
+ const source = base();
30
+ if (!fresh && cache && cache.source === source && Date.now() - cache.at < TTL_MS) return cache;
31
+ let j;
32
+ try { j = JSON.parse((await get(`${source}/index.json`, MAX_INDEX)).toString("utf8")); }
33
+ catch (e) { throw new Error(`Couldn't load the playbook library (${e.message.replace(/\.$/, "")}). Check your connection, or the library address in Settings.`); }
34
+ const playbooks = (Array.isArray(j?.playbooks) ? j.playbooks : []).filter((p) => p && /^[a-z0-9][a-z0-9-]{0,39}$/.test(p.id) && FILE.test(p.file) && /^[0-9a-f]{64}$/.test(p.sha256))
35
+ .map((p) => ({
36
+ id: p.id, name: String(p.name || p.id).slice(0, 60), category: String(p.category || "").slice(0, 80), tagline: String(p.tagline || "").slice(0, 300),
37
+ accent: /^#[0-9a-f]{3,6}$/i.test(p.accent || "") ? p.accent : null, url: typeof p.url === "string" && /^https?:\/\//.test(p.url) ? p.url : null,
38
+ journey: Array.isArray(p.journey) ? p.journey.slice(0, 10).map((s) => String(s).slice(0, 60)) : [], facts: Number(p.facts) || 0,
39
+ author: String(p.author || "").slice(0, 80), file: p.file, sha256: p.sha256,
40
+ }));
41
+ cache = { source, at: Date.now(), playbooks };
42
+ return cache;
43
+ }
44
+
45
+ /** Each library playbook with whether you have it: available, installed, update, or taken (you have your own with that id). */
46
+ export async function libraryView() {
47
+ const idx = await libraryIndex();
48
+ const mine = new Map(CUSTOM_PLAYBOOKS.playbooks.map((p) => [p.id, p]));
49
+ return {
50
+ source: idx.source, fetchedAt: new Date(idx.at).toISOString(),
51
+ playbooks: idx.playbooks.map((p) => {
52
+ const have = mine.get(p.id);
53
+ const state = !have ? "available" : have.origin?.library !== idx.source ? "taken" : have.origin.sha256 === p.sha256 ? "installed" : "update";
54
+ return { ...p, state };
55
+ }),
56
+ };
57
+ }
58
+
59
+ /** Download, verify and save one playbook. `overwrite` replaces your own file with the same id. */
60
+ export async function installFromLibrary(id, { overwrite = false } = {}) {
61
+ const idx = await libraryIndex({ fresh: true });
62
+ const entry = idx.playbooks.find((p) => p.id === id);
63
+ if (!entry) return { status: 404, errors: ["That playbook isn't in the library."] };
64
+ const have = CUSTOM_PLAYBOOKS.playbooks.find((p) => p.id === id);
65
+ if (have && !overwrite && have.origin?.library !== idx.source) return { status: 409, errors: [`You already have your own playbook called “${id}”. Rename yours first, or replace it.`] };
66
+ const buf = await get(`${idx.source}/${entry.file}`, MAX_FILE);
67
+ if (createHash("sha256").update(buf).digest("hex") !== entry.sha256) return { status: 502, errors: ["The download doesn't match the library's checksum, so it wasn't installed."] };
68
+ let raw;
69
+ try { raw = JSON.parse(buf.toString("utf8")); } catch { return { status: 502, errors: ["The library file isn't valid JSON."] }; }
70
+ if (raw.id !== id) return { status: 502, errors: ["The library file's id doesn't match its listing."] };
71
+ delete raw.origin; // only the installer writes this
72
+ const check = validatePlaybook(raw);
73
+ if (check.errors) return { status: 502, errors: check.errors.map((e) => `Library file: ${e}`) };
74
+ const r = savePlaybook({ ...raw, origin: { library: idx.source, sha256: entry.sha256, installedAt: new Date().toISOString() } }, { previousId: have ? id : undefined });
75
+ return r.errors ? { status: 400, errors: r.errors } : { playbook: r.playbook };
76
+ }
package/lib/links.mjs ADDED
@@ -0,0 +1,148 @@
1
+ // Share links: a URL you give customers (/c/<id>) that opens a call already set up
2
+ // for one product and case, with no pickers and no console.
3
+ //
4
+ // The id is a random 128-bit capability. Anyone holding it can start calls with
5
+ // that link's settings, and nothing else: no dashboard, no other products, no
6
+ // other people's calls. Pause or delete a link to stop it working. Links are
7
+ // saved to LINKS_FILE (default ~/.ava-engine/links.json, readable only by you).
8
+
9
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { dirname, join } from "node:path";
12
+ import { randomBytes } from "node:crypto";
13
+
14
+ const FILE = process.env.LINKS_FILE || join(process.env.AVA_HOME || join(homedir(), ".ava-engine"), "links.json");
15
+ const ID = /^[A-Za-z0-9_-]{22}$/;
16
+ const today = () => new Date().toISOString().slice(0, 10);
17
+
18
+ let links = load();
19
+
20
+ function load() {
21
+ try {
22
+ const j = JSON.parse(readFileSync(FILE, "utf8"));
23
+ return Array.isArray(j.links) ? j.links.filter((l) => ID.test(l?.id)) : [];
24
+ } catch { return []; }
25
+ }
26
+ function save() {
27
+ mkdirSync(dirname(FILE), { recursive: true });
28
+ const tmp = `${FILE}.${process.pid}.tmp`;
29
+ writeFileSync(tmp, JSON.stringify({ links }, null, 2), { mode: 0o600 });
30
+ renameSync(tmp, FILE); // atomic: a crash never leaves half a file
31
+ try { chmodSync(FILE, 0o600); } catch {}
32
+ }
33
+
34
+ const str = (v, max) => typeof v === "string" && v.trim().length > 0 && v.length <= max;
35
+
36
+ /** Check dashboard input. `productExists(id)` says whether a playbook id is real. Returns { value } or { errors }. */
37
+ export function validateLink(input, { productExists }) {
38
+ const i = input && typeof input === "object" ? input : {};
39
+ const errors = [];
40
+ if (!str(i.name, 80)) errors.push("Give the link a name (up to 80 characters), e.g. “Acme onboarding: new trials”.");
41
+ const mode = i.mode === "flexible" ? "flexible" : "onboarding";
42
+ const product = i.product ? String(i.product) : null;
43
+ if (mode === "onboarding" && !product) errors.push("Pick the product this link onboards people onto.");
44
+ if (product && !productExists(product)) errors.push(`There's no playbook called “${product}”.`);
45
+ for (const [k, max] of [["title", 80], ["intro", 240]]) if (i[k] != null && i[k] !== "" && !str(i[k], max)) errors.push(`“${k}” can be up to ${max} characters.`);
46
+ let expiresAt = null;
47
+ if (i.expiresAt) {
48
+ const t = Date.parse(i.expiresAt);
49
+ if (!Number.isFinite(t)) errors.push("The expiry date isn't a valid date.");
50
+ else expiresAt = new Date(t).toISOString();
51
+ }
52
+ let dailyLimit = null;
53
+ if (i.dailyLimit != null && i.dailyLimit !== "") {
54
+ dailyLimit = Number(i.dailyLimit);
55
+ if (!(Number.isInteger(dailyLimit) && dailyLimit >= 1 && dailyLimit <= 100_000)) errors.push("The daily call limit must be a whole number from 1 to 100,000.");
56
+ }
57
+ if (errors.length) return { errors };
58
+ return {
59
+ value: {
60
+ name: i.name.trim(), mode, product,
61
+ title: str(i.title, 80) ? i.title.trim() : null,
62
+ intro: str(i.intro, 240) ? i.intro.trim() : null,
63
+ handoff: i.handoff !== false, steps: i.steps !== false, recap: i.recap !== false,
64
+ expiresAt, dailyLimit, enabled: i.enabled !== false,
65
+ },
66
+ };
67
+ }
68
+
69
+ export function listLinks() { return links.map((l) => ({ ...l, usage: usage(l) })); }
70
+ export function getLink(id) { return typeof id === "string" && ID.test(id) ? links.find((l) => l.id === id) || null : null; }
71
+
72
+ export function createLink(value) {
73
+ const now = new Date().toISOString();
74
+ const link = { id: randomBytes(16).toString("base64url"), ...value, createdAt: now, updatedAt: now, calls: 0, lastUsedAt: null, day: { date: today(), n: 0 } };
75
+ links.unshift(link);
76
+ save();
77
+ return link;
78
+ }
79
+ export function updateLink(id, value) {
80
+ const l = getLink(id);
81
+ if (!l) return null;
82
+ Object.assign(l, value, { updatedAt: new Date().toISOString() });
83
+ save();
84
+ return l;
85
+ }
86
+ export function deleteLink(id) {
87
+ const n = links.length;
88
+ links = links.filter((l) => l.id !== id);
89
+ if (links.length !== n) { save(); return true; }
90
+ return false;
91
+ }
92
+
93
+ function usage(l) {
94
+ const n = l.day?.date === today() ? l.day.n : 0;
95
+ return { today: n, total: l.calls || 0, lastUsedAt: l.lastUsedAt };
96
+ }
97
+
98
+ /** Why a link can't start a call right now (shown to the customer), or null. */
99
+ export function linkBlocked(l) {
100
+ if (!l) return "This link doesn't exist. Ask whoever sent it for a new one.";
101
+ if (!l.enabled) return "This link is paused right now. Try again later.";
102
+ if (l.expiresAt && Date.parse(l.expiresAt) <= Date.now()) return "This link has expired. Ask whoever sent it for a new one.";
103
+ if (l.dailyLimit && usage(l).today >= l.dailyLimit) return "This link has reached today's limit. Try again tomorrow.";
104
+ return null;
105
+ }
106
+
107
+ /** Count a call started from a link. */
108
+ export function countLinkCall(l) {
109
+ if (l.day?.date !== today()) l.day = { date: today(), n: 0 };
110
+ l.day.n += 1; l.calls = (l.calls || 0) + 1; l.lastUsedAt = new Date().toISOString();
111
+ try { save(); } catch (e) { console.warn(" [links] couldn't save usage:", e.message); }
112
+ }
113
+
114
+ /** What the customer's page needs. Nothing internal (name, limits, usage). */
115
+ export function publicLinkView(l, platform) {
116
+ return {
117
+ id: l.id, mode: l.mode,
118
+ title: l.title || (platform ? `Get set up on ${platform.name}` : "Talk to Ava"),
119
+ intro: l.intro || (platform ? `Ava will guide you through ${platform.name}, step by step, on your own screen.` : "Share your screen and ask anything. Ava sees what you see and helps you through it."),
120
+ platform: platform ? { id: platform.id, name: platform.name, accent: platform.accent || null, tagline: platform.tagline || "", journey: platform.journey || [] } : null,
121
+ options: { handoff: l.handoff, steps: l.steps, recap: l.recap },
122
+ };
123
+ }
124
+
125
+ // The customer's browser proves it opened /c/<id> with a per-link cookie, so
126
+ // WebSockets and every API call are covered without the operator password.
127
+ export const linkCookieName = (id) => `ava_l_${id}`;
128
+ export function linkCookieHeader(id, secure) {
129
+ return `${linkCookieName(id)}=1; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200${secure ? "; Secure" : ""}`;
130
+ }
131
+ /**
132
+ * Live links whose cookie this request carries. Pausing, expiring or deleting a
133
+ * link cuts its calls off; the daily limit only stops new calls (linkBlocked),
134
+ * so it never drops someone mid-call.
135
+ */
136
+ export function linksFromRequest(req) {
137
+ const out = [];
138
+ for (const part of String(req.headers.cookie || "").split(";")) {
139
+ const m = part.trim().match(/^ava_l_([A-Za-z0-9_-]{22})=1$/);
140
+ if (!m) continue;
141
+ const l = getLink(m[1]);
142
+ if (l && l.enabled && !(l.expiresAt && Date.parse(l.expiresAt) <= Date.now())) out.push(l);
143
+ }
144
+ return out;
145
+ }
146
+
147
+ export const LINKS_FILE = FILE;
148
+ export function linksFileExists() { return existsSync(FILE); }
package/lib/mock.mjs ADDED
@@ -0,0 +1,88 @@
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
+ // A customer on a share link can't add a key, so they get the same honesty
25
+ // without the operator's instructions (o.customer is set for link calls).
26
+
27
+ /** Greeting. */
28
+ export function greet(o) {
29
+ if (o.customer) {
30
+ const what = o.mode === "onboarding" && o.platform ? `your ${o.platform.name} guide` : "your live guide";
31
+ return `Hi, I'm Ava, ${what}. Heads up: this is a demo, so my replies are canned for now. Voice and screen sharing work, so go ahead and try it.`;
32
+ }
33
+ if (o.mode === "onboarding" && o.platform) {
34
+ 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.`;
35
+ }
36
+ 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.`;
37
+ }
38
+
39
+ /** The talker's reply to a user turn. `kind` is the router's mode (help/support/wrap…). */
40
+ export function reply(o, userText, kind = "") {
41
+ if (/WRAP/i.test(kind)) return "Demo reply: no problem, we can pick this up whenever you like.";
42
+ const c = !!o.customer;
43
+ if (/SUPPORT/i.test(kind)) return c ? "Demo reply: sorry this is being a pain. I'm in demo mode right now, so the quickest help is “Talk to a person”." : `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”.`;
44
+ const asked = quote(userText);
45
+ const step = currentStep(o);
46
+ if (step?.title) {
47
+ return `Demo reply: on ${o.platform.name}, the next step is “${step.title}”${step.how ? `: ${step.how}` : ""}. ${c ? "In demo mode I can't read your screen yet." : `In demo mode I can't read your screen, so ${KEY_HINT} and I'll guide you based on what's actually there.`}`;
48
+ }
49
+ if (sharing(o)) {
50
+ return c ? "Demo reply: you're sharing a tab, but in demo mode I can't read it yet."
51
+ : `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.`;
52
+ }
53
+ if (c) return asked ? `Demo reply: you asked “${asked}”. I'm in demo mode, so I can't answer that for real yet.` : "Demo reply: I'm in demo mode, so I can't answer for real yet.";
54
+ return asked
55
+ ? `Demo reply: you asked “${asked}”. I'm in demo mode, so ${KEY_HINT}. Sharing a tab works now too.`
56
+ : `Demo reply: I'm in demo mode, so ${KEY_HINT}.`;
57
+ }
58
+
59
+ /** Perception: honest about not reading the screen. */
60
+ export function vision() {
61
+ return JSON.stringify({
62
+ app: "Shared tab", page: "not read in demo mode", elements: [], content: [],
63
+ state: "Demo mode can't read screens without an OpenRouter key.",
64
+ look: "", activity: "no real change",
65
+ });
66
+ }
67
+
68
+ export function thinking() {
69
+ return JSON.stringify({ resolved: false, stuck: false, blocker: "", thought: "Demo mode: no reasoning model is connected.", nextTactic: "" });
70
+ }
71
+
72
+ export function planner(o) {
73
+ if (o.mode === "onboarding" && o.platform) {
74
+ 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" });
75
+ }
76
+ 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" });
77
+ }
78
+
79
+ /** Intention guess while browsing silently: low confidence, so it's ignored. */
80
+ export function guess() {
81
+ return JSON.stringify({ goal: "explore Ava in demo mode", understanding: "demo mode", confidence: 0.2 });
82
+ }
83
+
84
+ export function nudge(o) {
85
+ return o.mode === "onboarding" && o.platform
86
+ ? `Demo mode: share your ${o.platform.name} tab or ask me about a step to see how I respond.`
87
+ : "Demo mode: share a tab or ask me something to see how I respond.";
88
+ }
package/lib/models.mjs CHANGED
@@ -1,18 +1,21 @@
1
1
  // Central model config. Override any of these via env (see .env.example).
2
2
  // Slugs are OpenRouter model ids. Change freely — the UI shows whatever is set.
3
3
 
4
- export const MODELS = {
4
+ import { onSettingsChange } from "./settings.mjs";
5
+
6
+ const models = () => ({
5
7
  talker: process.env.MODEL_TALKER || "meta-llama/llama-3.3-70b-instruct", // the SPOKEN reply — latency-critical; on Groq ~0.2s TTFT (vs ~0.7s on gemini-flash-lite)
6
8
  perception: process.env.MODEL_PERCEPTION || "google/gemini-2.5-flash",
7
9
  planner: process.env.MODEL_PLANNER || "anthropic/claude-sonnet-4.5",
8
10
  intent: process.env.MODEL_INTENT || "google/gemini-2.5-flash-lite",
9
- };
11
+ });
12
+ export const MODELS = models();
10
13
 
11
14
  // Per-role provider routing (OpenRouter). Only the talker is latency-critical —
12
15
  // it's the reply the user actually hears — so we prefer Groq's very low
13
16
  // time-to-first-token. allow_fallbacks keeps replies working (just slower) if
14
17
  // Groq is briefly unavailable. Set TALKER_PROVIDER=auto to let OpenRouter pick.
15
- export const PROVIDER = {
18
+ const providers = () => ({
16
19
  talker: process.env.TALKER_PROVIDER === "auto" ? undefined
17
20
  : { order: [process.env.TALKER_PROVIDER || "groq"], allow_fallbacks: true },
18
21
  // Perception (vision) is on the turn's hot path when the screen changed, so it
@@ -20,7 +23,10 @@ export const PROVIDER = {
20
23
  // default vision model may not be served by that provider).
21
24
  perception: process.env.PERCEPTION_PROVIDER && process.env.PERCEPTION_PROVIDER !== "auto"
22
25
  ? { order: [process.env.PERCEPTION_PROVIDER], allow_fallbacks: true } : undefined,
23
- };
26
+ });
27
+ export const PROVIDER = providers();
28
+ // Settings saved in the dashboard apply to the next call (the objects are updated in place).
29
+ onSettingsChange(() => { Object.assign(MODELS, models()); Object.assign(PROVIDER, providers()); });
24
30
 
25
31
  // Fallback price table ($ per 1M tokens) used only if OpenRouter doesn't
26
32
  // return a usage.cost for a call. Approximate; edit to taste. [in, out]
@@ -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.
@@ -10,7 +11,10 @@ function callSignal(ms) {
10
11
  }
11
12
 
12
13
  const OR_URL = "https://openrouter.ai/api/v1/chat/completions";
13
- export const MOCK = !process.env.OPENROUTER_API_KEY;
14
+ import { onSettingsChange } from "./settings.mjs";
15
+ // Live binding: adding or removing the key in the dashboard switches modes at once.
16
+ export let MOCK = !process.env.OPENROUTER_API_KEY;
17
+ onSettingsChange((k) => { if (k.has("OPENROUTER_API_KEY")) MOCK = !process.env.OPENROUTER_API_KEY; });
14
18
  // Optional app attribution on openrouter.ai (https://openrouter.ai/docs/app-attribution).
15
19
  const ATTRIBUTION = {
16
20
  ...(process.env.OPENROUTER_REFERER ? { "HTTP-Referer": process.env.OPENROUTER_REFERER } : {}),
@@ -63,6 +67,7 @@ export async function chat({ model, role, messages, maxTokens = 350, temperature
63
67
  };
64
68
  }
65
69
 
70
+ if (dailyCapReached()) throw new DailyCapError();
66
71
  const body = {
67
72
  model,
68
73
  messages,
@@ -96,6 +101,7 @@ export async function chat({ model, role, messages, maxTokens = 350, temperature
96
101
  const promptTokens = u.prompt_tokens ?? 0;
97
102
  const completionTokens = u.completion_tokens ?? 0;
98
103
  const cost = typeof u.cost === "number" ? u.cost : estimateCost(model, promptTokens, completionTokens);
104
+ recordSpend(cost);
99
105
  return { text, model, role, promptTokens, completionTokens, cost, latencyMs, mocked: false };
100
106
  }
101
107
 
@@ -113,6 +119,7 @@ export async function chatStream({ model, role, messages, maxTokens = 220, tempe
113
119
  return { text, model, role, promptTokens, completionTokens, cost: estimateCost(model, promptTokens, completionTokens), latencyMs: Date.now() - t0, mocked: true };
114
120
  }
115
121
 
122
+ if (dailyCapReached()) throw new DailyCapError();
116
123
  const res = await fetch(OR_URL, {
117
124
  method: "POST",
118
125
  headers: {
@@ -149,5 +156,6 @@ export async function chatStream({ model, role, messages, maxTokens = 220, tempe
149
156
  const promptTokens = usage?.prompt_tokens ?? 0;
150
157
  const completionTokens = usage?.completion_tokens ?? 0;
151
158
  const cost = typeof usage?.cost === "number" ? usage.cost : estimateCost(model, promptTokens, completionTokens);
159
+ recordSpend(cost);
152
160
  return { text, model, role, promptTokens, completionTokens, cost, latencyMs: Date.now() - t0, mocked: false };
153
161
  }
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.
@@ -576,7 +577,9 @@ export async function sessionSummary(sess) {
576
577
  highlights: [],
577
578
  };
578
579
  const p = out ? parseJSON(out.text, fallback) : fallback;
579
- return { headline: p.headline || o.goal, summary: p.summary || "", highlights: Array.isArray(p.highlights) ? p.highlights.slice(0, 4) : [], goal: o.goal, session: scoreView(sess.score.ours) };
580
+ const recap = { headline: p.headline || o.goal, summary: p.summary || "", highlights: Array.isArray(p.highlights) ? p.highlights.slice(0, 4) : [] };
581
+ sess.recap = recap; // kept with the transcript, if transcripts are on
582
+ return { ...recap, goal: o.goal, session: scoreView(sess.score.ours) };
580
583
  }
581
584
 
582
585
  /* ---------- barge-in truncation ---------- */
@@ -631,8 +634,19 @@ export async function observeUpdate(sess, frameDataUrl, frameHash, force = false
631
634
  // believable connecting → waiting state. (No live agent backend here.)
632
635
  export function requestHandoff(sess) {
633
636
  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 };
637
+ const first = !o.handoff.requested;
638
+ if (first) o.handoff = { requested: true, state: "connecting", at: Date.now() };
639
+ const specialistKey = mintSpecialistKey(sess);
640
+ if (first) {
641
+ const wf = o.workflow;
642
+ emitEvent("handoff.requested", {
643
+ sessionId: sess.id, specialistUrl: specialistUrl(specialistKey), mode: o.mode,
644
+ platform: o.platform ? { id: o.platform.id, name: o.platform.name } : null, goal: o.goal !== "—" ? o.goal : null,
645
+ 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,
646
+ lastUserMessage: [...o.history].reverse().find((t) => t.role === "user")?.text?.slice(0, 200) || null,
647
+ });
648
+ }
649
+ return { handoff: o.handoff, specialistKey, workflow: o.workflow ? workflowView(o) : null };
636
650
  }
637
651
  export function handoffStatus(sess) {
638
652
  const o = sess.ours;
@@ -710,7 +724,7 @@ async function guessIntention(o, usages) {
710
724
  { 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
725
  { role: "user", content: `Screens/actions (oldest first): ${acts}\nCurrent screen: ${screenLine(o, true)}\nPrevious guess: ${o.goal}` },
712
726
  ];
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 }) });
727
+ const out = await chat({ model: MODELS.intent, role: "intent", messages, maxTokens: 90, temperature: 0.3, mock: () => demo.guess() });
714
728
  usages.push(out);
715
729
  o.lastGuessAt = Date.now();
716
730
  const p = parseJSON(out.text, null);
@@ -753,7 +767,7 @@ export async function proactiveNudge(sess) {
753
767
  { role: "system", content: momentSys },
754
768
  { 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
769
  ];
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?" });
770
+ const out = await chat({ model: MODELS.talker, role: "talker", provider: PROVIDER.talker, messages, maxTokens: 60, temperature: 0.7, mock: () => demo.nudge(o) });
757
771
  usages.push(out); addTokens(sess, "ours", usages);
758
772
  const t = sanitizeReply(stripLabel(out.text));
759
773
  const base = { goal: o.goal, understanding: o.understanding, goalGuessedNow: !!guessedNow, workflow: o.workflow ? workflowView(o) : null, session: scoreView(sess.score.ours) };