ava-engine 0.0.0-stage → 0.2.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.
Files changed (104) hide show
  1. package/.env.example +63 -0
  2. package/CLA.md +43 -0
  3. package/COMMERCIAL-LICENSE.md +43 -0
  4. package/LICENSE +73 -0
  5. package/README.md +240 -2
  6. package/THIRD_PARTY_NOTICES.md +38 -0
  7. package/TRADEMARKS.md +31 -0
  8. package/bin/ava-engine.mjs +12 -0
  9. package/lib/asr.mjs +147 -0
  10. package/lib/cli/apply.mjs +58 -0
  11. package/lib/cli/config.mjs +95 -0
  12. package/lib/cli/main.mjs +187 -0
  13. package/lib/cli/setup-server.mjs +195 -0
  14. package/lib/cli/setup.mjs +233 -0
  15. package/lib/fetch-models.mjs +104 -0
  16. package/lib/guard.mjs +92 -0
  17. package/lib/kokoro-model.mjs +51 -0
  18. package/lib/models.mjs +47 -0
  19. package/lib/openrouter.mjs +153 -0
  20. package/lib/ours.mjs +766 -0
  21. package/lib/platforms.mjs +201 -0
  22. package/lib/state.mjs +150 -0
  23. package/lib/stt.mjs +22 -0
  24. package/lib/tts.mjs +178 -0
  25. package/lib/workflow.mjs +18 -0
  26. package/package.json +80 -4
  27. package/public/app.js +1708 -0
  28. package/public/demo.js +420 -0
  29. package/public/favicon.svg +1 -0
  30. package/public/fonts/OFL.txt +96 -0
  31. package/public/fonts/inter-tight-var.woff2 +0 -0
  32. package/public/fonts/inter-var.woff2 +0 -0
  33. package/public/fonts/plex-mono-400.woff2 +0 -0
  34. package/public/fonts/plex-mono-500.woff2 +0 -0
  35. package/public/img/cta-1600.avif +0 -0
  36. package/public/img/cta-1600.webp +0 -0
  37. package/public/img/cta-2400.avif +0 -0
  38. package/public/img/cta-2400.webp +0 -0
  39. package/public/img/cta-960.avif +0 -0
  40. package/public/img/cta-960.webp +0 -0
  41. package/public/img/flows-1600.avif +0 -0
  42. package/public/img/flows-1600.webp +0 -0
  43. package/public/img/flows-2400.avif +0 -0
  44. package/public/img/flows-2400.webp +0 -0
  45. package/public/img/flows-960.avif +0 -0
  46. package/public/img/flows-960.webp +0 -0
  47. package/public/img/hero-1600.avif +0 -0
  48. package/public/img/hero-1600.webp +0 -0
  49. package/public/img/hero-2400.avif +0 -0
  50. package/public/img/hero-2400.webp +0 -0
  51. package/public/img/hero-960.avif +0 -0
  52. package/public/img/hero-960.webp +0 -0
  53. package/public/img/p-csm-480.avif +0 -0
  54. package/public/img/p-csm-480.webp +0 -0
  55. package/public/img/p-csm-800.avif +0 -0
  56. package/public/img/p-csm-800.webp +0 -0
  57. package/public/img/p-docs-480.avif +0 -0
  58. package/public/img/p-docs-480.webp +0 -0
  59. package/public/img/p-docs-800.avif +0 -0
  60. package/public/img/p-docs-800.webp +0 -0
  61. package/public/img/p-quiet-480.avif +0 -0
  62. package/public/img/p-quiet-480.webp +0 -0
  63. package/public/img/p-quiet-800.avif +0 -0
  64. package/public/img/p-quiet-800.webp +0 -0
  65. package/public/img/p-tickets-480.avif +0 -0
  66. package/public/img/p-tickets-480.webp +0 -0
  67. package/public/img/p-tickets-800.avif +0 -0
  68. package/public/img/p-tickets-800.webp +0 -0
  69. package/public/img/p-trial-480.avif +0 -0
  70. package/public/img/p-trial-480.webp +0 -0
  71. package/public/img/p-trial-800.avif +0 -0
  72. package/public/img/p-trial-800.webp +0 -0
  73. package/public/img/sees-1600.avif +0 -0
  74. package/public/img/sees-1600.webp +0 -0
  75. package/public/img/sees-2400.avif +0 -0
  76. package/public/img/sees-2400.webp +0 -0
  77. package/public/img/sees-960.avif +0 -0
  78. package/public/img/sees-960.webp +0 -0
  79. package/public/img/speaks-1600.avif +0 -0
  80. package/public/img/speaks-1600.webp +0 -0
  81. package/public/img/speaks-2400.avif +0 -0
  82. package/public/img/speaks-2400.webp +0 -0
  83. package/public/img/speaks-960.avif +0 -0
  84. package/public/img/speaks-960.webp +0 -0
  85. package/public/img/voice-1600.avif +0 -0
  86. package/public/img/voice-1600.webp +0 -0
  87. package/public/img/voice-2400.avif +0 -0
  88. package/public/img/voice-2400.webp +0 -0
  89. package/public/img/voice-960.avif +0 -0
  90. package/public/img/voice-960.webp +0 -0
  91. package/public/index.html +440 -0
  92. package/public/landing.html +975 -0
  93. package/public/pcm-worklet.js +11 -0
  94. package/public/perceptionWorker.js +103 -0
  95. package/public/rtc.js +60 -0
  96. package/public/setup.css +94 -0
  97. package/public/setup.html +129 -0
  98. package/public/setup.js +264 -0
  99. package/public/specialist.html +92 -0
  100. package/public/specialist.js +132 -0
  101. package/public/style.css +836 -0
  102. package/public/vendor/lenis.min.js +3 -0
  103. package/scripts/fetch-models.sh +15 -0
  104. package/server.mjs +502 -0
package/lib/ours.mjs ADDED
@@ -0,0 +1,766 @@
1
+ // The flexible agent. Per turn: perception (diff-gated, rich) + intent (parallel)
2
+ // → deterministic router → a MULTIMODAL talker that actually sees the screen and
3
+ // drives toward a held goal → async planner that keeps goal + grounded plan.
4
+ // A background observe loop keeps perception fresh and lets the agent react
5
+ // proactively when the screen changes (parity with continuous-vision agents).
6
+
7
+ import { chat, chatStream, estTokens } from "./openrouter.mjs";
8
+ import { MODELS, PROVIDER } from "./models.mjs";
9
+ import { record, scoreView, addTokens, mintSpecialistKey, revokeSpecialistKey } from "./state.mjs";
10
+
11
+ const HAMMING_SAME = 20; // on the 2048-bit (32×32) dHash — ~1%; a tab switch or text change flips more
12
+ const HISTORY_MAX = 60; // prompts read at most the last 30 entries; keep a little slack
13
+
14
+ function remember(o, role, text) {
15
+ o.history.push({ role, text });
16
+ if (o.history.length > HISTORY_MAX) o.history.splice(0, o.history.length - HISTORY_MAX);
17
+ }
18
+
19
+ const TALKER_SYS = [
20
+ "You are Ava, a sharp, proactive, screen-aware assistant. The SCREEN section in the context is a live reading of exactly what is on the user's screen right now (from a vision model) — treat it as your own eyes.",
21
+ "PERSONALITY: you're quick-witted, playful, and a little cheeky — the fun colleague who happens to be great at their job. Laugh when something's genuinely funny, tease gently, celebrate small wins ('there it is!'), and show real empathy when something goes sideways. If something on their screen is absurd or amusing, you're allowed to enjoy it out loud. Dry humor welcome; corporate blandness is not.",
22
+ "You've got their back. If you spot something risky or costly — a huge number, a likely typo, a destructive click, a charge that looks wrong ($10,000 when they probably meant $100) — SPEAK UP with genuine concern before they commit: 'Whoa, hold on — that says ten grand, did you mean a hundred?'. Catching that is the single most valuable thing you do, so never let it slide by with a bland remark.",
23
+ "Answer what the user actually asked FIRST, directly and concretely. If they ask what's on their screen, describe it from the SCREEN reading. Only then, if useful, offer one next step.",
24
+ "The GOAL line is background context, not a mandate. The user's LATEST message always wins: when they change topic, task, or app, drop the old thread completely and follow them — never steer the conversation back to a previous task, and never keep hunting for something they've moved past.",
25
+ "Ground every instruction in the SCREEN reading: name the exact button/label/section listed and where it is. If they can't find something, use the SCREEN details to say precisely where it is, or say honestly it isn't listed and what to do instead.",
26
+ "When you name, recommend, or point at CONTENT — a video, an email, a plan, a price, a search result, a file — use ONLY the real items in the SCREEN's ON-SCREEN CONTENT list, quoted as written, and USE ALL of them (if four plans with prices are listed, you know all four — never claim a listed one 'isn't shown'). NEVER invent a title, channel, price, or plan that isn't listed.",
27
+ "But do NOT flatly contradict the user about their own screen. If they insist something is there that you don't see in the list, it may be below the fold, in a menu, or just appeared — believe them: say you might not have the full view, ask them to scroll to it or point at it, and re-read rather than arguing 'it's not there'. Only say an item isn't present after you've genuinely looked and it's still absent.",
28
+ "NEVER send the user to a specific tab, section, or button to FIND missing information when you're only guessing it lives there (e.g. 'click the X tab to see the prices'). Guessing a destination sends them on a wrong chase. Only point at a control that's actually in the SCREEN reading. If the thing they want isn't in the reading, say plainly you don't see it here and ask them to scroll or point to it, or take another look — do not invent where it 'probably' is.",
29
+ "You have TASTE. When asked what you think of a design, a page, or a choice, GIVE a real opinion — commit to a view, grounded in the SCREEN reading's look/colors/layout, with one specific observation and (when useful) one suggestion. NEVER say you can't think, can't judge, or can't interpret design — the look line IS you seeing it.",
30
+ "Never say 'I can't see it' while a SCREEN reading exists. If the reading lacks the specific detail they're asking about, say what you DO see and ask them to point at or open the part they mean.",
31
+ "Use the SCREEN 'state' notes (what's selected, disabled, greyed/coloured) to reason about whether a step worked.",
32
+ "If the context says the screen CHANGED since your last reply, or the SCREEN no longer matches the step you were on, acknowledge it first in a few words (e.g. 'looks like you moved to X') and re-orient — never blindly continue the previous instruction.",
33
+ "If something didn't work, take a concrete DIFFERENT step. Never loop, and never keep asking 'do you see it?'.",
34
+ "When the user asks the SAME thing again, or asks whether something will work / is safe / what happens if they do it, do NOT repeat your previous instruction. ANSWER the real question concretely — say what the action actually does and what happens next (e.g. 'Yep — clicking Purchase charges your card the $10.85 and the credits land in your balance right away'), then reassure. Re-sending a near-identical message is a failure.",
35
+ "A REASONING line may appear in the context — that's your own honest read of whether they're stuck, hesitant, or done. Treat it as a directive: act on it, don't restate your last line.",
36
+ "Talk like a real person, not a script. NEVER start with 'It looks like you're on…' and don't narrate the screen every time — vary your wording, and often just give the next step directly, the way a friend sitting next to them would.",
37
+ "If SCREEN says 'not shared', you genuinely cannot see anything — NEVER invent or guess screen contents, and say so plainly if they ask about their screen. But if their question has nothing to do with the screen, just answer it — don't mention what you can or can't see.",
38
+ "Be brief, warm, human. One clear next step per turn. Never end with filler like 'Is there anything else I can help you with?'.",
39
+ "Write PLAIN spoken text only — no markdown, no asterisks, no **bold**, no bullet points, no headings, no emoji. Just natural sentences, like a person talking.",
40
+ ].join("\n");
41
+
42
+ // Extra contract when the user joined as a VOICE call — replies are heard, not
43
+ // read, so they must sound like a person on a call, not a chatbot.
44
+ const VOICE_STYLE = [
45
+ "This is a LIVE VOICE CALL — your reply is spoken aloud. Keep it to one or two short sentences (under ~35 words), then stop.",
46
+ "Short does NOT mean flat. Sound warm, engaged, and human: use contractions, react naturally, vary your phrasing — like a sharp colleague on a call, not a status readout.",
47
+ "Be fun to talk to. React with genuine energy ('Oh, that green is bold — I like it'), take positions, tease gently when it fits. When they ask for your opinion, LEAD with the opinion, then the reason — never with a disclaimer.",
48
+ "Answer the literal question first, in as few words as it deserves: 'what is 2 plus 2' gets 'Four.' — and if the question has nothing to do with the screen, do not mention the screen or what you can or can't see at all.",
49
+ "When the answer is a 'no' or 'I don't see it', always add the one most useful follow-up — where it probably lives, a good guess, or what you can do instead. Never stop dead on a negative.",
50
+ "NEVER give the same answer twice. If they ask again or rephrase, they need something DIFFERENT: add detail, offer a guess, or say what you're unsure about — repeating your previous sentence word-for-word is the worst thing you can do.",
51
+ "Their words arrive through speech recognition and may be garbled ('scream' for 'screen', 'glas' for 'last'). Infer what they most likely meant from context and answer THAT; if you genuinely can't tell, ask a quick friendly clarifying question.",
52
+ "Never re-describe the screen and never repeat an instruction you already gave. If they check in ('are you there?'), just answer warmly in a few words.",
53
+ "Talk like you're mid-conversation: no preamble, no summaries, no sign-off questions.",
54
+ ].join("\n");
55
+
56
+ // Proactive speech: every fresh screen read reaches Ava and SHE decides
57
+ // whether this is a moment to speak — no client- or server-side lanes,
58
+ // no rate-limit heuristics. One brain, one judgment call.
59
+ const MOMENT_SYS = [
60
+ "You are Ava — playful, sharp, a little cheeky — on a LIVE CALL, watching the user's screen as they work. The screen just changed. Decide like a person would: is this a moment to speak?",
61
+ "SPEAK when a person watching would: they arrived somewhere new (glance over, react naturally); they completed a step (acknowledge in a word and give the next one toward the GOAL); they undid, closed, or abandoned something (react with real empathy or curiosity AND follow up — 'closed it without buying — everything okay, or want a hand?'); something looks RISKY, COSTLY, or WRONG (a huge amount, a likely typo, a destructive action — jump in with genuine concern BEFORE they commit: 'ten thousand dollars? double-check that's not a typo'); or something is genuinely funny (enjoy it out loud).",
62
+ "You are protective and engaged, not a passive narrator. A big number, a surprising change, or a reversal is your cue to REACT with feeling — surprise, concern, delight — never a flat observation. If you just watched them do something and there's an obvious 'wait, are you sure?', say it.",
63
+ "Lean toward engaging — a quiet assistant feels dead, and being present is the whole point. STAY QUIET (reply with exactly SKIP) only when a person truly would: they're mid-keystroke and don't need you, nothing actually changed, or anything you'd say just repeats a recent line.",
64
+ "When you speak: ONE short, natural, spoken sentence — and prefer SUBSTANCE over narration. Announcing the screen ('Oh, the credits page') is only for a genuinely new arrival; otherwise advance the GOAL, offer a concrete insight or save ('that auto top-up is what's greying out the button'), or check your read of what they're after ('Looks like you're trying to top up — want a hand?'). Never ask check-in questions ('are you finding it helpful?'), never repeat or paraphrase your recent lines. Plain text only, no markdown or emoji.",
65
+ ].join("\n");
66
+
67
+ // ── Onboarding mode: turn Ava from a do-anything assistant into a focused
68
+ // product-onboarding specialist for one platform. Appended AFTER the base
69
+ // persona so its focus overrides the flexible "follow them anywhere" line. ──
70
+ function onboardingAddendum(p) {
71
+ return [
72
+ ``,
73
+ `ONBOARDING MODE — you are a product-onboarding specialist for ${p.name} (${p.category}). This OVERRIDES the "follow them anywhere / goal is background" guidance above.`,
74
+ `Your ONE job: get this brand-new user signed in and all the way into their ${p.name} dashboard, fast and without overwhelm. You are DONE the moment they reach ${p.firstWin}.`,
75
+ `You know the path cold: ${p.path.join(" → ")}. Guide ONE concrete step at a time, always grounded in the SCREEN reading — name the exact button/field to use and where it is.`,
76
+ `Confirm each step worked from the SCREEN before moving to the next. If a step fails or looks different, adapt from what you actually SEE — never recite the next step blindly.`,
77
+ `VERIFY before you celebrate: only say a step is done — deployed, created, signed in, provisioned — when the SCREEN explicitly confirms it (a success message, a live URL, a 'Deployed'/'Healthy'/'Created' status, the new item clearly listed). A projects list or dashboard that may hold pre-existing items is NOT proof. If they cancelled, backed out, or you can't see the confirmation, do NOT congratulate them — say what you see and walk them through actually completing it.`,
78
+ `WHEN THE DASHBOARD IS REACHED (confirmed): stop. Celebrate it plainly ("you're in — that's your dashboard, nicely done") and DON'T invent extra chores. ${p.optional || ""}`.trim(),
79
+ `TRUST THE SCREEN over your memory of the product. If you sent them somewhere and they say it isn't there, believe them — read what's actually visible now and point to THAT. Never send them back to a section you already tried; that back-and-forth is the worst failure mode.`,
80
+ `Stay on task, but don't fabricate steps to prolong it. If they wander, warmly steer back ("one more step and you're at the dashboard"). Never dump the whole checklist at once, and never repeat an instruction you just gave — if they're stuck, do something DIFFERENT.`,
81
+ ].join("\n");
82
+ }
83
+ const ONBOARDING_MOMENT = "\nYOU ARE ONBOARDING them onto this specific product, and the screen JUST CHANGED — react now, don't wait to be asked. If they completed a step (with real on-screen proof), acknowledge it in a few words and give the very next action. If they landed on a new page, orient them and point at the next step. If they cancelled, backed out, or the expected result isn't visible, gently say what you see and help them finish it. Only reply SKIP if the screen truly did not meaningfully change. Never wait silently while they navigate.";
84
+
85
+ // Build the talker's system prompt for this session: base persona, + onboarding
86
+ // focus when in onboarding mode, + voice-call style when it's a voice call.
87
+ function talkerSystem(o, voiceMode) {
88
+ let sys = TALKER_SYS;
89
+ if (o.mode === "onboarding" && o.platform) sys += "\n" + onboardingAddendum(o.platform);
90
+ if (voiceMode) sys += "\n" + VOICE_STYLE;
91
+ return sys;
92
+ }
93
+
94
+ function hamming(a, b) {
95
+ if (!a || !b || a.length !== b.length) return 64;
96
+ let d = 0;
97
+ for (let i = 0; i < a.length; i++) { let x = parseInt(a[i], 16) ^ parseInt(b[i], 16); while (x) { d += x & 1; x >>= 1; } }
98
+ return d;
99
+ }
100
+ function parseJSON(text, fallback) { try { const m = text.match(/\{[\s\S]*\}/); return JSON.parse(m ? m[0] : text); } catch { return fallback; } }
101
+ // Models sometimes prefix their line with a speaker label ("Ava: ...") — strip
102
+ // it so it never gets spoken or stored in history.
103
+ function stripLabel(s) { return String(s || "").replace(/^\s*(ava|assistant|ai|agent)\s*[:>\-–]\s*/i, "").trim(); }
104
+ // The talker is fed the compiled context as its user message, and occasionally
105
+ // echoes part of that prompt back as its "reply" (e.g. the "SCREEN CHANGED…"
106
+ // directive, or a "visible: … state: …" dump). Cut the reply at the first such
107
+ // leaked marker so a prompt fragment is never spoken or shown.
108
+ const LEAK_MARKERS = [/SCREEN CHANGED/i, /\bGOAL\s*[:(]/i, /\bSCREEN:\s/i, /\bvisible:\s/i, /\bstate:\s/i, /USER (INTENT|JUST SAID)/i, /RECENT ACTIVITY/i, /\bMODE:\s+[A-Z]/, /COMPILED CONTEXT/i, /\bPLAN:\s/i, /\bGUIDANCE:\s/i, /ONBOARDING MODE/i];
109
+ function sanitizeReply(s) {
110
+ let t = String(s || "");
111
+ for (const m of LEAK_MARKERS) { const i = t.search(m); if (i >= 0) t = t.slice(0, i); }
112
+ return t.trim();
113
+ }
114
+ // Jaccard word-overlap similarity — used to stop the agent repeating itself.
115
+ function similar(a, b) {
116
+ const w = (s) => new Set(String(s || "").toLowerCase().replace(/[^a-z0-9 ]/g, " ").split(/\s+/).filter((x) => x.length > 2));
117
+ const A = w(a), B = w(b); if (!A.size || !B.size) return 0;
118
+ let inter = 0; for (const x of A) if (B.has(x)) inter++;
119
+ return inter / (A.size + B.size - inter);
120
+ }
121
+ function activityLine(o) { return o.activity.length ? o.activity.slice(-6).map((a) => "• " + a.action).join("\n") : "—"; }
122
+
123
+ /* ---------- 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
+ // Serialize perception per session: /api/observe and /api/turn can arrive
132
+ // concurrently, and both write screenState/lastHash — unserialized, a slow
133
+ // response can overwrite a fresher read and clobber the diff-gate hash.
134
+ function perceive(o, ...args) {
135
+ const run = (o._perceiveLock || Promise.resolve()).then(() => doPerceive(o, ...args));
136
+ o._perceiveLock = run.then(() => {}, () => {});
137
+ return run;
138
+ }
139
+ async function doPerceive(o, frameDataUrl, screenActive, frameHash, userText, usages, force) {
140
+ if (!screenActive) { o.screenState = o.screenState ? { ...o.screenState, stale: true } : null; return { ran: false, discarded: false, reason: "not sharing" }; }
141
+ if (!frameDataUrl) { return { ran: false, discarded: false, reason: "no frame this turn (keeping last read)" }; } // sharing but capture missed — don't go stale
142
+ const dist = hamming(o.lastHash, frameHash);
143
+ // force = a real user turn → always re-read (never guide off a stale screen).
144
+ // The diff-gate only saves calls in the background observe loop.
145
+ if (!force && o.lastHash != null && dist <= HAMMING_SAME) return { ran: false, discarded: true, reason: `diff ${dist} bits ≤ ${HAMMING_SAME} — reuse v${o.screenVersion}` };
146
+ const prevSummary = o.screenState && !o.screenState.stale
147
+ ? `${o.screenState.app} · ${o.screenState.page} — ${o.screenState.state || ""} — visible: ${(o.screenState.elements || []).join(" | ")}`
148
+ : "none (this may be the first screen)";
149
+ const messages = [
150
+ { 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
+ { 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
+ ];
153
+ const out = await chat({ model: MODELS.perception, role: "perception", provider: PROVIDER.perception, messages, maxTokens: 1100, temperature: 0.1, mock: () => mockVision(userText) });
154
+ usages.push(out);
155
+ const parsed = parseJSON(out.text, null);
156
+ const usable = parsed && ((parsed.elements && parsed.elements.length) || (parsed.state && String(parsed.state).length > 3) || (parsed.app && !/^unknown/i.test(parsed.app)));
157
+ if (!usable) {
158
+ // vision returned nothing usable (usually a truncated/garbled JSON) — DON'T go blind; keep the last good reading
159
+ if (o.screenState && !o.screenState.stale) { o.lastHash = frameHash; return { ran: false, discarded: false, reason: `vision unusable — kept last read v${o.screenVersion}` }; }
160
+ // no good previous read either: don't write an "unknown" state; leave the hash unset so the next frame retries
161
+ return { ran: true, discarded: false, reason: "vision unusable — will retry on the next frame" };
162
+ }
163
+ o.lastHash = frameHash; o.screenVersion += 1;
164
+ o.screenState = { ...parsed, v: o.screenVersion, stale: false };
165
+ const act = ((parsed && parsed.activity) || "").trim();
166
+ if (act && !/^no( real)? change/i.test(act)) { o.activity.push({ at: Date.now(), action: act }); if (o.activity.length > 10) o.activity.shift(); }
167
+ return { ran: true, discarded: false, reason: `change (diff ${dist} bits) → vision v${o.screenVersion}`, activity: act };
168
+ }
169
+
170
+ /* ---------- 2. Intent (fast, parallel) ---------- */
171
+ function mockIntent(userText) {
172
+ const t = (userText || "").toLowerCase();
173
+ let intent = "task_help", frustrated = false, request = "";
174
+ if (/frustrat|angry|noo+|ugh|annoy|don'?t know|idk|hate/i.test(t)) { frustrated = true; intent = "frustrated"; }
175
+ if (/\?|how|what|why|where|can you|help/.test(t)) intent = frustrated ? "frustrated" : "question";
176
+ if (/stop|end (this|the)? ?call|done with|bye|quit/.test(t)) intent = "wants_to_stop";
177
+ if (/^(hi|hey|hello|yo|sup|nice|thanks|ok)\b/.test(t)) intent = "smalltalk";
178
+ return JSON.stringify({ intent, frustrated, request });
179
+ }
180
+ async function classifyIntent(o, userText, usages) {
181
+ const recent = o.history.slice(-6).map((t) => `${t.role}: ${t.text}`).join("\n");
182
+ const messages = [
183
+ { role: "system", content: 'Classify the user\'s latest message in a live screen-share help session. Output ONLY JSON: {"intent":"task_help|question|frustrated|wants_to_stop|smalltalk","frustrated":true|false,"request":"<=8 words on what they actually want, else empty"}.' },
184
+ { role: "user", content: `Conversation:\n${recent || "(none)"}\n\nLatest: "${userText}"` },
185
+ ];
186
+ const out = await chat({ model: MODELS.intent, role: "intent", messages, maxTokens: 70, temperature: 0, mock: () => mockIntent(userText) });
187
+ usages.push(out);
188
+ const p = parseJSON(out.text, { intent: "task_help", frustrated: false, request: "" });
189
+ return { intent: p.intent || "task_help", frustrated: !!p.frustrated, request: p.request || "", usage: out };
190
+ }
191
+
192
+ /* ---------- 3. Thinking (reasoning check: are we actually getting anywhere?) ---------- */
193
+ // A short, honest reasoning pass the USER can see. It answers the questions a
194
+ // good human helper asks themselves: is this working, are they stuck, is the
195
+ // step actually done — and if stuck, what genuinely DIFFERENT thing to try.
196
+ const THINK_SYS = [
197
+ "You are the reasoning check for a screen-aware assistant. Look at the goal, what's on the screen now, what the user just said, what they've been doing, and the assistant's own recent instructions — and judge, honestly, whether things are actually working.",
198
+ 'Output ONLY JSON: {"resolved":true|false,"stuck":true|false,"blocker":"<one short line: what is actually blocking them right now, else empty>","thought":"<one short line of plain reasoning the user can read>","nextTactic":"<one short line: a genuinely DIFFERENT thing to try if stuck, else empty>"}.',
199
+ "stuck = the user has tried the same thing more than once, OR says something isn't working / can't be clicked / won't clear / is just text, OR the screen hasn't advanced since your last instruction, OR they keep asking whether it will work / hesitate to act (they want a concrete answer and reassurance, NOT the instruction repeated). When stuck, nextTactic MUST be a different route — NEVER a reword of the last instruction.",
200
+ "resolved = the current step or the whole goal is genuinely done, with explicit on-screen PROOF (a success message, a live/deployed URL, a 'Deployed'/'Healthy'/'Created' status, the created item clearly present). Being on a projects list or dashboard that could contain pre-existing items is NOT proof. If they cancelled, closed a dialog, or the confirmation isn't visible, resolved=false — and say so in 'thought' so the assistant doesn't wrongly congratulate them.",
201
+ "Trust the SCREEN and the user's words over your assumptions. If they say a control can't be clicked, believe them and reason about why (wrong element, it's a label not a link, a different screen than you think) and route around it.",
202
+ "Keep 'thought' to one honest sentence — it's shown live so a reviewer can see your reasoning.",
203
+ ].join("\n");
204
+ async function think(o, userText, usages) {
205
+ const recent = o.history.slice(-8).map((t) => `${t.role}: ${t.text}`).join("\n") || "—";
206
+ const myLines = o.history.slice(-6).filter((t) => t.role === "assistant").map((t) => t.text).join(" | ") || "—";
207
+ const messages = [
208
+ { role: "system", content: THINK_SYS },
209
+ { 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
+ ];
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: "" }) });
212
+ usages.push(out);
213
+ const p = parseJSON(out.text, { resolved: false, stuck: false, blocker: "", thought: "", nextTactic: "" });
214
+ o.lastThink = { resolved: !!p.resolved, stuck: !!p.stuck, blocker: p.blocker || "", thought: p.thought || "", nextTactic: p.nextTactic || "" };
215
+ return { ...o.lastThink, usage: out };
216
+ }
217
+
218
+ /* ---------- workflow tracker (detect real progress through the journey) ---------- */
219
+ function cap(s) { s = String(s || "").trim(); return s.charAt(0).toUpperCase() + s.slice(1); }
220
+ function workflowView(o) {
221
+ const wf = o.workflow; if (!wf) return null;
222
+ const done = wf.steps.filter((s) => s.status === "done").length;
223
+ return { title: wf.title, steps: wf.steps.map((s) => ({ title: s.title, status: s.status })), current: wf.current, done, total: wf.steps.length, etaMin: wf.etaMin, note: wf.note, handoff: o.handoff };
224
+ }
225
+ function buildWorkflowFromPlan(o) {
226
+ if (o.workflow || !o.plan || !o.plan.length) return;
227
+ 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
+ }
229
+ function applyWorkflow(o, p) {
230
+ const wf = o.workflow; if (!wf || !p) return;
231
+ const n = wf.steps.length;
232
+ let cur = Number.isInteger(p.current) ? p.current : wf.current;
233
+ cur = Math.max(0, Math.min(n - 1, cur));
234
+ const done = new Set((p.done || []).filter((i) => Number.isInteger(i) && i >= 0 && i < n));
235
+ for (let i = 0; i < cur; i++) done.add(i); // steps before the current one are implicitly complete
236
+ const before = wf.steps.map((s) => s.status).join(",") + "|" + wf.current;
237
+ // NOT sticky: recompute every call so the tracker can REGRESS when the user
238
+ // cancels or backs out — a step is "done" only if this fresh read still says so.
239
+ wf.current = cur;
240
+ wf.steps.forEach((s, i) => { s.status = done.has(i) ? "done" : (i === cur ? "active" : "todo"); });
241
+ // Onboarding's final step is an "arrival / first win" step (open your dashboard,
242
+ // see it in Today) — reaching it IS the finish, so mark it done. The tracker only
243
+ // advances to the last step once the earlier ones are proven on screen, so this
244
+ // won't fire early. (Flexible tasks keep the strict all-steps-done rule.)
245
+ if (o.mode === "onboarding" && n > 0 && cur === n - 1) wf.steps[n - 1].status = "done";
246
+ wf.note = typeof p.note === "string" ? p.note : "";
247
+ if (typeof p.etaMin === "number") wf.etaMin = Math.max(0, Math.round(p.etaMin));
248
+ wf.changed = before !== (wf.steps.map((s) => s.status).join(",") + "|" + wf.current);
249
+ // The whole journey is finished when EVERY step is done on screen. This is the
250
+ // "stop" signal — the agent should celebrate once and go quiet, not keep
251
+ // guiding. Recomputed each call, so it regresses if the user backs out.
252
+ wf.complete = wf.steps.length > 0 && wf.steps.every((s) => s.status === "done");
253
+ o.done = wf.complete;
254
+ if (!o.done) o.doneAnnounced = false; // regressed → allow a fresh wrap-up later
255
+ }
256
+ async function trackWorkflow(o, usages) {
257
+ if (!o.workflow) return null;
258
+ const steps = o.workflow.steps.map((s, i) => `${i}: ${s.title}`).join("\n");
259
+ const messages = [
260
+ { role: "system", content: 'Given an ordered checklist and what is on the user\'s screen right now, report their progress HONESTLY and STRICTLY. Output ONLY JSON: {"current":<index of the step they are working on now>,"done":[<indices fully completed>],"note":"<=6 words naming the exact sub-action happening now, else empty","etaMin":<whole minutes left, rough>}.\nRULES:\n- A step is DONE only with explicit on-screen PROOF it finished: a success message, a live/deployed URL, a "Deployed"/"Healthy"/"Created" status, or the created item clearly present. Being on a page that merely COULD contain results (a projects list, a dashboard that may show pre-existing items) is NOT proof.\n- If the user cancelled, closed a dialog, hit an error, or the expected confirmation is NOT visible, the step is NOT done — set "current" to that step (it is fine to move BACKWARD from a previous reading).\n- When the FINAL step is confirmed complete on screen (the end goal / first win is visible), include EVERY index in "done" and set "current" to the last index — the whole task is finished.\n- Do not assume forward progress. Report only what the screen actually shows.' },
261
+ { role: "user", content: `CHECKLIST:\n${steps}\nSCREEN NOW: ${screenLine(o, true)}\nRECENT ACTIVITY (newest last):\n${activityLine(o)}` },
262
+ ];
263
+ const out = await chat({ model: MODELS.intent, role: "workflow", messages, maxTokens: 120, temperature: 0.1, mock: () => JSON.stringify({ current: o.workflow.current, done: o.workflow.steps.map((s, i) => i).filter((i) => o.workflow.steps[i].status === "done"), note: o.workflow.note, etaMin: o.workflow.etaMin }) });
264
+ usages.push(out);
265
+ const p = parseJSON(out.text, null);
266
+ if (p) applyWorkflow(o, p);
267
+ return p;
268
+ }
269
+
270
+ /* ---------- RAG: retrieve product knowledge (keyword overlap over the seeded KB) ---------- */
271
+ function retrieveKnowledge(o, query) {
272
+ const kb = (o.platform && o.platform.knowledge) || [];
273
+ if (!kb.length) { o.knowledgeUsed = []; return []; }
274
+ const ss = o.screenState && !o.screenState.stale ? `${o.screenState.app} ${o.screenState.page} ${o.screenState.state || ""} ${(o.screenState.elements || []).join(" ")}` : "";
275
+ const terms = new Set((query + " " + ss).toLowerCase().replace(/[^a-z0-9 ]/g, " ").split(/\s+/).filter((w) => w.length > 2));
276
+ const scored = kb.map((s) => ({ s, score: s.k.split(/\s+/).reduce((n, k) => n + (terms.has(k) ? 1 : 0), 0) }));
277
+ scored.sort((a, b) => b.score - a.score);
278
+ const hits = scored.filter((x) => x.score >= 1).slice(0, 2).map((x) => x.s.text);
279
+ o.knowledgeUsed = hits;
280
+ return hits;
281
+ }
282
+
283
+ /* ---------- compiled context (blackboard) ---------- */
284
+ function screenLine(o, screenActive) {
285
+ const ss = o.screenState;
286
+ if (!ss || ss.stale) {
287
+ return screenActive
288
+ ? "shared, but the first read isn't in yet — say you're just taking a look, and don't guess any specifics"
289
+ : "not shared — you cannot see the user's screen right now, so do not describe or guess any screen contents";
290
+ }
291
+ const content = Array.isArray(ss.content) && ss.content.length ? `\n ON-SCREEN CONTENT (real items, top-to-bottom — the FIRST is the newest/topmost; reference ONLY these, verbatim; for a "latest"/"most recent" ask use the first): ${ss.content.map((c, i) => `${i + 1}. ${c}`).join(" ")}` : "";
292
+ return `${ss.app} · ${ss.page}\n visible: ${(ss.elements || []).join(" | ")}${content}\n state: ${ss.state || "—"}\n look: ${ss.look || "—"}`;
293
+ }
294
+ function compileContext(o, route, intent, userText, screenActive) {
295
+ const app = o.screenState && !o.screenState.stale ? o.screenState.app : null;
296
+ const planStale = o.plan.length && o.planApp && app && app !== o.planApp;
297
+ const planLine = !o.plan.length ? "—"
298
+ : planStale ? `(made for ${o.planApp} — likely stale now that they're in ${app}; ignore if the user has moved on)`
299
+ : o.plan.map((p, i) => `${i + 1}) ${p}`).join(" ");
300
+ const recent = o.history.slice(-6).map((t) => `${t.role}: ${t.text}`).join("\n") || "—";
301
+ const seen = o.screenState && !o.screenState.stale ? `${o.screenState.app} · ${o.screenState.page}` : null;
302
+ const changed = seen && o.lastTalkerScreen && seen !== o.lastTalkerScreen;
303
+ // The goal was formed on a different app than the one they're looking at now
304
+ // → it's stale THIS turn (the planner only re-derives it after we reply, so
305
+ // without this she clings to the old goal on the very turn they pivot).
306
+ // In onboarding mode the goal is fixed (get them into the dashboard) — it never
307
+ // goes stale on an app switch; the whole point is to bring them back on task.
308
+ const onboarding = o.mode === "onboarding" && o.platform;
309
+ const goalStale = !onboarding && o.planApp && app && app !== o.planApp && o.goal !== "—";
310
+ return [
311
+ onboarding
312
+ ? `GOAL (stay focused — you are onboarding them onto ${o.platform.name}; keep steering here): ${o.goal}`
313
+ : goalStale
314
+ ? `GOAL: "${o.goal}" — ⚠ this was for ${o.planApp}; they're now in ${app}. DROP it and follow the SCREEN + their latest message. Do not steer back to it.`
315
+ : `GOAL (background only — the user's latest message wins): ${o.goal}`,
316
+ changed ? `⚠ SCREEN CHANGED since your last reply — acknowledge the new screen and re-orient; do NOT just continue the previous step.` : ``,
317
+ intent ? `USER INTENT: ${intent.intent}${intent.frustrated ? " · FRUSTRATED" : ""}${intent.request ? ` · wants: ${intent.request}` : ""}`
318
+ : o.lastIntent ? `USER INTENT (read a moment ago): ${o.lastIntent.intent}${o.lastIntent.frustrated ? " · FRUSTRATED" : ""}${o.lastIntent.request ? ` · wants: ${o.lastIntent.request}` : ""}` : ``,
319
+ `SCREEN: ${screenLine(o, screenActive)}`,
320
+ // RAG — inject retrieved product knowledge so answers are precise, not guessed
321
+ (() => { const kn = retrieveKnowledge(o, userText); return kn.length ? `PRODUCT KNOWLEDGE (retrieved from ${o.platform ? o.platform.name : "the product"} docs — use it to answer precisely; do not contradict or invent beyond it):\n${kn.map((t) => "• " + t).join("\n")}` : ""; })(),
322
+ `RECENT ACTIVITY (what the user has been doing on screen — reason about it):\n${activityLine(o)}`,
323
+ // Completion latch — the whole task is finished and confirmed on screen. This
324
+ // OVERRIDES the goal/plan: stop guiding, don't restart it, don't re-give steps.
325
+ o.done
326
+ ? `✓ TASK COMPLETE — every step is done and confirmed on screen; they've reached the goal. Do NOT give any more steps, do NOT re-explain the task, do NOT invent extra chores. If they're thanking you or wrapping up, respond warmly and briefly and let them go. Only take a new action if they explicitly ask for something new.`
327
+ : ``,
328
+ // Reasoning check — ALWAYS surfaced so the talker acts on a fresh diagnosis
329
+ // of whether they're stuck, done, or hesitant, instead of blindly continuing.
330
+ o.lastThink && o.lastThink.thought
331
+ ? `REASONING (your own honest read of the situation — you MUST act on this, do not ignore it): ${o.lastThink.thought}`
332
+ + (o.lastThink.stuck ? ` ⚠ STUCK: ${o.lastThink.blocker || "they're not getting through"}. Do NOT repeat your last instruction — instead ${o.lastThink.nextTactic || "answer the worry underneath their words and try a different route"}.` : ``)
333
+ + (o.lastThink.resolved ? ` ✓ This step is DONE on screen — acknowledge it and move on; don't re-give it.` : ``)
334
+ : ``,
335
+ `PLAN: ${planLine}`,
336
+ `GUIDANCE: ${o.guidance}`,
337
+ `MODE: ${route.mode.toUpperCase()} — ${route.instruction}`,
338
+ `RECENT:\n${recent}`,
339
+ `USER JUST SAID: "${userText}"`,
340
+ `(Ground your reply in the SCREEN reading above. Answer what they asked; the GOAL is context, not an agenda.)`,
341
+ ].filter(Boolean).join("\n");
342
+ }
343
+
344
+ /* ---------- 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
+ async function plan(o, usages) {
349
+ const hist = o.history.slice(-10).map((t) => `${t.role}: ${t.text}`).join("\n");
350
+ const messages = [
351
+ { 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
+ { 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
+ ];
354
+ const out = await chat({ model: MODELS.planner, role: "planner", messages, maxTokens: 200, temperature: 0.4, mock: () => mockPlanner(o) });
355
+ usages.push(out);
356
+ const p = parseJSON(out.text, null);
357
+ 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; }
358
+ // Onboarding: the destination is fixed. Let the planner set the next STEPS,
359
+ // but keep the goal anchored on getting them into the dashboard.
360
+ if (o.mode === "onboarding" && o.platform) o.goal = `Get set up on ${o.platform.name} and into the dashboard`;
361
+ o.planApp = o.screenState && !o.screenState.stale ? o.screenState.app : null; // remember what the plan was made for
362
+ o.goalGuessed = false; // the deep planner's goal is authoritative, not a guess
363
+ return out;
364
+ }
365
+
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
+ /* ---------- greeting ---------- */
376
+ export async function greetStream(sess, emit, voiceMode = false) {
377
+ const o = sess.ours; const usages = [];
378
+ const sys = talkerSystem(o, voiceMode);
379
+ const greetMsg = (o.mode === "onboarding" && o.platform)
380
+ ? `GREET: open the call as a ${o.platform.name} onboarding specialist. In 1–2 short warm sentences, welcome them by name of the product, say you'll get them into their ${o.platform.name} dashboard quickly, and ask them to share the ${o.platform.name} tab${o.platform.demoOnly ? "" : ` (or open ${o.platform.url})`} so you can guide them step by step.`
381
+ : "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
+ const messages = [{ role: "system", content: sys }, { role: "user", content: greetMsg }];
383
+ 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
+ tk.text = sanitizeReply(stripLabel(tk.text));
386
+ usages.push(tk); remember(sess.ours, "assistant", tk.text); pushBridge(sess, "ava", tk.text);
387
+ 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 });
388
+ if (o.workflow) emit("workflow", workflowView(o)); // populate the tracker at call start
389
+ const hot = record(sess, "ours", usages, tk.latencyMs);
390
+ emit("metrics", { perModel: usages.map((u) => ({ role: u.role, model: u.model, tokens: u.promptTokens + u.completionTokens, cost: u.cost, latencyMs: u.latencyMs })), hotLatencyMs: hot, session: scoreView(sess.score.ours) });
391
+ emit("done", {});
392
+ }
393
+
394
+ /* ---------- deterministic quick router (instant, no model) ---------- */
395
+ function quickRoute(o, userText) {
396
+ const t = (userText || "").toLowerCase();
397
+ const app = o.screenState && !o.screenState.stale ? o.screenState.app : null;
398
+ const SUPPORT_MSG = "They're stuck or frustrated — acknowledge it genuinely, look at the screen, and take a concrete new step yourself; don't ask them to look again.";
399
+ let mode = "help", instruction, bumped = false;
400
+ if (/\b(stop|end (the )?call|bye|quit|that'?s all|we'?re done|i'?m done|no thanks)\b/.test(t)) { mode = "wrap"; instruction = "They want to pause — acknowledge warmly and let them go."; }
401
+ else if (/frustrat|angry|ugh+|annoy|stupid|hate|i don'?t know|idk|wtf|come on|seriously/.test(t)) { mode = "support"; o.frustration = Math.min(3, o.frustration + 1); bumped = true; instruction = SUPPORT_MSG; }
402
+ else if (o.frustration >= 2) { mode = "support"; o.frustration -= 1; instruction = SUPPORT_MSG; } // stays supportive, but calm turns decay it
403
+ else { o.frustration = Math.max(0, o.frustration - 1); instruction = app ? `They're in ${app}. Look at the attached screen and give one precise, grounded next step toward the goal.` : "Help with what they asked; offer to look at their screen if it would help."; }
404
+ return { mode, instruction, app, frustration: o.frustration, bumped, willPlan: o.goal === "—" || o.turnsSincePlan >= 2 || o.frustration >= 2 };
405
+ }
406
+
407
+ /* ---------- one full turn ---------- */
408
+ // How long we'll wait for a fresh screen read before talking anyway.
409
+ //
410
+ // The key fact: a screen that hasn't changed is diff-gated, so perceive() returns
411
+ // almost INSTANTLY — the budget never bites and those turns are fast (~0.4s). The
412
+ // budget only matters when a real vision read RUNS (the screen actually changed),
413
+ // and vision costs ~2–2.6s here regardless of model or image size. On exactly
414
+ // those turns the user is asking about the NEW screen, so grounding on a fresh
415
+ // read matters more than shaving a second — otherwise the reply describes the OLD
416
+ // screen (e.g. "$20 selected" after they switched to $10). So we wait for the
417
+ // read. A read that overruns even this finishes in the background and grounds the
418
+ // next turn. Tune with PERCEIVE_BUDGET_MS (lower = snappier but may lag one beat
419
+ // on a just-changed screen).
420
+ const PERCEIVE_BUDGET_MS = Number(process.env.PERCEIVE_BUDGET_MS) || 2800;
421
+
422
+ export async function oursTurnStream(sess, userText, frameDataUrl, screenActive, frameHash, emit, voiceMode = false, changedHint = false) {
423
+ const o = sess.ours; const usages = [];
424
+ remember(o, "user", userText); pushBridge(sess, "user", userText);
425
+
426
+ // deterministic router — instant, no model, no waiting
427
+ const route = quickRoute(o, userText);
428
+ emit("gate", { mode: route.mode, app: route.app, frustration: route.frustration, willPlan: route.willPlan, reason: route.instruction });
429
+
430
+ // Intent runs concurrently. Its read is used in this turn's context when it lands
431
+ // in time (it usually beats a vision re-read), and always feeds the frustration
432
+ // signal + the next turn. Errors degrade to the regex route — they never escape.
433
+ let intentNow = null;
434
+ const iP = classifyIntent(o, userText, usages)
435
+ .then((i) => {
436
+ intentNow = { intent: i.intent, frustrated: i.frustrated, request: i.request };
437
+ o.lastIntent = intentNow;
438
+ if (i.frustrated && !route.bumped) o.frustration = Math.min(3, o.frustration + 1);
439
+ emit("intent", { intent: i.intent, frustrated: i.frustrated, request: i.request, model: MODELS.intent, tokens: i.usage.promptTokens + i.usage.completionTokens, latencyMs: i.usage.latencyMs, cost: i.usage.cost });
440
+ })
441
+ .catch((e) => emit("intent", { intent: "unavailable", frustrated: false, request: "", error: String(e?.message || e) }));
442
+
443
+ // Thinking runs concurrently too — a visible reasoning check on whether the
444
+ // user is actually progressing or stuck. Its verdict feeds this turn's context
445
+ // (so the talker adapts instead of repeating) and is surfaced in the transcript.
446
+ const kP = think(o, userText, usages)
447
+ .then((r) => emit("thinking", { resolved: r.resolved, stuck: r.stuck, blocker: r.blocker, thought: r.thought, nextTactic: r.nextTactic, model: MODELS.intent, tokens: r.usage.promptTokens + r.usage.completionTokens, latencyMs: r.usage.latencyMs, cost: r.usage.cost }))
448
+ .catch((e) => emit("thinking", { thought: "", error: String(e?.message || e) }));
449
+
450
+ // Perception: diff-gated re-read BEFORE the talker speaks, so the reply is
451
+ // grounded in the CURRENT screen. Unchanged screen → the gate skips the model
452
+ // call and resolves instantly. Slow vision → talk on the last good read rather
453
+ // than stall past the budget. Errors keep the last read — they never escape.
454
+ let perceptionPending = null;
455
+ if (screenActive && frameDataUrl) {
456
+ sess.score.ours.perception.captured += 1;
457
+ // When the user is explicitly asking Ava to LOOK or about what's on screen
458
+ // (contents, options, prices, what's selected), force a fresh read even if the
459
+ // coarse gate would reuse the last one — small but important changes (a moved
460
+ // selection, a newly revealed panel) can fall under the hash threshold.
461
+ // Two cases force a fresh read: (1) the user asks Ava to look / about screen
462
+ // contents, and (2) the user DISPUTES what Ava just said about the screen
463
+ // ("no, it's already there", "look again") — that's exactly when a stale or
464
+ // partial cached read needs refreshing before we answer.
465
+ const wantsFreshLook = /\b(what('?s| is| are| do)?\b.*\b(screen|see|show|there|here|option|plan|price|credit|selected|listed)|do you see|can you see|look (at|again)|check again|on (the|my) screen|read (the|my) screen|what am i|which (one|plan|option)|the options?\b|all (the|my) (option|plan))/i.test(userText || "")
466
+ || /\b(no,?\s+(it'?s|they'?re|there)|it'?s (already|right) (there|here|on|shown|showing|visible)|already (on the screen|there|shown|showing|visible|listed)|it'?s on the screen|its on the screen|you'?re wrong|look again|i can see (it|them|that)|they'?re (there|here|shown|listed))\b/i.test(userText || "");
467
+ // changedHint: the client's fine-grained detector saw a change the server
468
+ // hasn't read yet — bypass the coarse hash gate for this turn's frame.
469
+ const pP = perceive(o, frameDataUrl, true, frameHash, userText, usages, changedHint || wantsFreshLook)
470
+ .then((p) => {
471
+ if (p.ran) sess.score.ours.perception.analysed += 1;
472
+ const pu = usages.find((u) => u.role === "perception");
473
+ emit("perceive", { ran: p.ran, discarded: p.discarded, reason: p.reason, screenState: o.screenState, model: MODELS.perception, tokens: pu ? pu.promptTokens + pu.completionTokens : 0, latencyMs: pu?.latencyMs || 0, cost: pu?.cost || 0 });
474
+ })
475
+ .catch((e) => emit("perceive", { ran: false, discarded: false, reason: `vision error — using last read (${String(e?.message || e).slice(0, 120)})`, screenState: o.screenState }));
476
+ // Gated (unchanged) reads resolve instantly → this race ends at once and the
477
+ // turn stays fast. A read that actually runs is worth waiting for (see above).
478
+ const winner = await Promise.race([pP, new Promise((r) => setTimeout(() => r("timeout"), PERCEIVE_BUDGET_MS))]);
479
+ if (winner === "timeout") perceptionPending = pP; // vision overran the budget → talk on last read now, ground the next turn
480
+ } else {
481
+ emit("perceive", { ran: false, discarded: false, reason: screenActive ? "no frame captured — using last read" : "not sharing", screenState: o.screenState });
482
+ }
483
+
484
+ // Thinking is OFF the hot path. Waiting for a fresh reasoning pass before every
485
+ // reply cost ~0.5–1s for little gain: "stuck / done" state persists across
486
+ // turns, so the talker grounds on the PREVIOUS turn's verdict — already carried
487
+ // in o.lastThink and baked into the compiled context below. This turn's pass
488
+ // runs in parallel, updates o.lastThink for the NEXT reply, and streams into the
489
+ // transcript as it lands; we await it only later, so its tokens are recorded.
490
+ // TALKER — grounded in the now-fresh blackboard (screen read, intent + last reasoning)
491
+ const compiled = compileContext(o, route, intentNow, userText, screenActive);
492
+ emit("context", { compiled, tokens: estTokens(compiled) });
493
+ emit("talkerStart", { model: MODELS.talker });
494
+ const sys = talkerSystem(o, voiceMode);
495
+ let tk;
496
+ 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
+ } catch (e) {
499
+ // The talker failed or the client hung up mid-reply. Calls that already ran
500
+ // (vision, intent, thinking) were billed — count them toward the budget.
501
+ await Promise.allSettled([iP, kP, perceptionPending]);
502
+ addTokens(sess, "ours", usages);
503
+ throw e;
504
+ }
505
+ tk.text = sanitizeReply(stripLabel(tk.text));
506
+ if (!tk.text) tk.text = "Sorry — let me look again. What are you seeing on the screen right now?"; // reply was pure prompt-echo; never speak blank
507
+ usages.push(tk); remember(o, "assistant", tk.text); o.lastSpokeAt = Date.now(); pushBridge(sess, "ava", tk.text);
508
+ 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 });
509
+ if (o.knowledgeUsed && o.knowledgeUsed.length) emit("knowledge", { count: o.knowledgeUsed.length, snippets: o.knowledgeUsed, source: o.platform ? o.platform.name : null });
510
+ if (o.screenState && !o.screenState.stale) o.lastTalkerScreen = `${o.screenState.app} · ${o.screenState.page}`;
511
+
512
+ await iP; // already caught — never throws
513
+ await kP; // thinking ran in parallel off the hot path — await here so its tokens/cost are recorded (already caught)
514
+ if (perceptionPending) await perceptionPending; // already caught — never throws
515
+
516
+ // Pivot detection: the user moved to a different app since the plan was made,
517
+ // or their message signals a topic switch → re-plan NOW, not two turns later.
518
+ const appNow = o.screenState && !o.screenState.stale ? o.screenState.app : null;
519
+ const appPivot = !!(appNow && o.planApp && appNow !== o.planApp);
520
+ // The user is stating a NEW thing to do → the current goal is stale, re-derive now.
521
+ const newTask = /\b(i (?:want|wanna|need|'?d like) to|i want|now (?:i|let'?s)|next,?\s|let'?s|help me|can you help|instead|switch(?:ing)? to|chang(?:e|ing) to|moved? (?:on|to)|actually,? let'?s|forget (?:that|it)|never ?mind|different (?:question|thing|topic)|new (?:question|topic|task))\b/i.test(userText);
522
+ // The previous task was confirmed done → whatever they say next is a fresh goal.
523
+ const afterDone = !!o.prevResolved;
524
+ const willPlan = route.willPlan || appPivot || newTask || afterDone;
525
+ if (appPivot || (newTask && appNow)) o.plan = []; // old plan was for the old task — don't let it steer anything
526
+
527
+ o.turnsSincePlan += 1;
528
+ if (willPlan) {
529
+ try {
530
+ const pl = await plan(o, usages); o.turnsSincePlan = 0;
531
+ emit("planner", { ran: true, goal: o.goal, understanding: o.understanding, plan: o.plan, guidance: o.guidance, model: MODELS.planner, tokens: pl.promptTokens + pl.completionTokens, latencyMs: pl.latencyMs, cost: pl.cost });
532
+ } catch (e) {
533
+ emit("planner", { ran: false, goal: o.goal, understanding: o.understanding, plan: o.plan, error: String(e?.message || e) });
534
+ }
535
+ } else {
536
+ emit("planner", { ran: false, goal: o.goal, understanding: o.understanding, plan: o.plan, turnsUntil: 2 - o.turnsSincePlan });
537
+ }
538
+
539
+ // workflow progress — build from the plan in flexible mode, then detect where
540
+ // the user actually is on the journey and push the updated tracker.
541
+ if (o.mode !== "onboarding" && (appPivot || newTask || afterDone)) { o.workflow = null; o.done = false; o.doneAnnounced = false; } // fresh task → fresh tracker + clear the done latch (onboarding journey is fixed)
542
+ buildWorkflowFromPlan(o);
543
+ if (o.workflow) { try { await trackWorkflow(o, usages); } catch {} }
544
+ emit("workflow", o.workflow ? workflowView(o) : null); // sync the tracker (null clears a stale one)
545
+
546
+ o.prevResolved = !!(o.lastThink && o.lastThink.resolved); // if this task is done, re-plan on the next message
547
+ const hot = record(sess, "ours", usages, tk.latencyMs); // hot latency = talker only now
548
+ emit("metrics", { goal: o.goal, perModel: usages.map((u) => ({ role: u.role, model: u.model, tokens: u.promptTokens + u.completionTokens, cost: u.cost, latencyMs: u.latencyMs })), hotLatencyMs: hot, session: scoreView(sess.score.ours) });
549
+ emit("done", {});
550
+ }
551
+
552
+ /* ---------- end-of-call summary (for the session report) ---------- */
553
+ export async function sessionSummary(sess) {
554
+ const o = sess.ours; const usages = [];
555
+ const convo = o.history.slice(-30).map((t) => `${t.role}: ${t.text}`).join("\n") || "(no conversation)";
556
+ const acts = o.activity.map((a) => a.action).join("; ") || "—";
557
+ const messages = [
558
+ { role: "system", content: 'Summarize this live screen-share voice session for a recap card the user sees after the call. Warm, concise, specific. Output ONLY JSON: {"headline":"<one short line naming what they were doing>","summary":"<2-3 sentences: what happened, what was accomplished, any snag>","highlights":["<up to 4 short bullet moments, each a few words>"]}.' },
559
+ { role: "user", content: `GOAL: ${o.goal}\nSCREEN ACTIVITY (oldest first): ${acts}\nCONVERSATION:\n${convo}` },
560
+ ];
561
+ const ask = (model) => chat({ model, role: "planner", messages, maxTokens: 300, temperature: 0.4,
562
+ mock: () => JSON.stringify({ headline: o.goal && o.goal !== "—" ? o.goal : "A quick session with Ava", summary: "MOCK mode recap: add an OPENROUTER_API_KEY for a real AI summary of the call.", highlights: ["Ran in MOCK mode with canned replies"] }) });
563
+ // A recap is the last thing the customer sees — one flaky upstream call
564
+ // shouldn't blank it. Try the planner model, then the faster intent model,
565
+ // then fall back to a plain recap built from the session itself.
566
+ let out = null;
567
+ for (const model of [MODELS.planner, MODELS.intent]) {
568
+ try { out = await ask(model); usages.push(out); break; }
569
+ catch (e) { console.error(" [summary]", model, e?.message || e); }
570
+ }
571
+ addTokens(sess, "ours", usages);
572
+ const replies = o.history.filter((t) => t.role === "assistant").length;
573
+ const fallback = {
574
+ headline: o.goal && o.goal !== "—" ? o.goal : "Session complete",
575
+ summary: `A ${replies}-reply session with Ava${o.activity.length ? `, across ${o.activity.length} screen changes` : ""}.`,
576
+ highlights: [],
577
+ };
578
+ 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
+ }
581
+
582
+ /* ---------- barge-in truncation ---------- */
583
+ // The user interrupted Ava mid-reply. Rewrite her last history entry to what
584
+ // was ACTUALLY heard, so her memory matches the user's ears — otherwise she
585
+ // keeps building on sentences the user never got.
586
+ export function truncateAssistant(sess, heard) {
587
+ const h = sess.ours.history;
588
+ for (let i = h.length - 1; i >= 0; i--) {
589
+ if (h[i].role === "assistant") {
590
+ h[i].text = heard && heard.trim()
591
+ ? heard.trim() + " …(the user interrupted here — the rest was never heard)"
592
+ : "(started to answer but the user interrupted immediately — they heard none of it)";
593
+ break;
594
+ }
595
+ }
596
+ return { ok: true };
597
+ }
598
+
599
+ /* ---------- continuous observe (background): keep fresh + optional proactive ---------- */
600
+ // Mark the screen as no longer shared (called when the user stops sharing).
601
+ export function screenOff(sess) {
602
+ const o = sess.ours;
603
+ if (o.screenState) o.screenState = { ...o.screenState, stale: true };
604
+ o.lastTalkerScreen = ""; o.lastProactive = 0;
605
+ return { screenState: o.screenState, session: scoreView(sess.score.ours) };
606
+ }
607
+
608
+ // Keep perception fresh (silent). Frequent; never speaks. `force` means the
609
+ // client's detector (hash + pixel diff) already decided this is a change —
610
+ // don't let the coarser server hash gate discard a text-level change like a
611
+ // tab switch inside the same modal.
612
+ export async function observeUpdate(sess, frameDataUrl, frameHash, force = false) {
613
+ const o = sess.ours; const usages = [];
614
+ const prev = o.screenState && !o.screenState.stale ? `${o.screenState.app} · ${o.screenState.page}` : null;
615
+ const percept = await perceive(o, frameDataUrl, true, frameHash, "", usages, force);
616
+ const nowScreen = o.screenState ? `${o.screenState.app} · ${o.screenState.page}` : null;
617
+ const navChanged = !!(nowScreen && nowScreen !== prev); // real navigation, not just a scroll
618
+ // A reversal or a problem (closed a modal, cancelled, hit an error) is the
619
+ // moment a person watching would say something — flag it for a fast nudge.
620
+ const salient = /clos(e|ed|ing)|cancel|dismiss|abandon|error|fail|went back|reverted|unchanged/i.test(percept.activity || "");
621
+ // Real-time workflow tracking: when the user navigates to a new page, re-detect
622
+ // where they are on the journey so the tracker advances (or regresses) live.
623
+ let stepChanged = false;
624
+ if (navChanged && o.workflow) { try { o.workflow.changed = false; await trackWorkflow(o, usages); stepChanged = !!o.workflow.changed; } catch {} }
625
+ addTokens(sess, "ours", usages);
626
+ return { ran: percept.ran, discarded: percept.discarded, reason: percept.reason, navChanged, stepChanged, salient, activity: percept.activity || "", screenState: o.screenState, workflow: o.workflow ? workflowView(o) : null, session: scoreView(sess.score.ours) };
627
+ }
628
+
629
+ /* ---------- human handoff ---------- */
630
+ // Prototype handoff: flag that the user wants a real person, move through a
631
+ // believable connecting → waiting state. (No live agent backend here.)
632
+ export function requestHandoff(sess) {
633
+ 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 };
636
+ }
637
+ export function handoffStatus(sess) {
638
+ const o = sess.ours;
639
+ if (o.handoff && o.handoff.state === "connecting" && Date.now() - (o.handoff.at || 0) > 2500) o.handoff.state = "waiting";
640
+ return { handoff: o.handoff, workflow: o.workflow ? workflowView(o) : null };
641
+ }
642
+ export function cancelHandoff(sess) {
643
+ sess.ours.handoff = { requested: false, state: "none" };
644
+ sess.ours.bridge.specialist = false;
645
+ revokeSpecialistKey(sess); // the shared link stops working
646
+ return { handoff: sess.ours.handoff, workflow: sess.ours.workflow ? workflowView(sess.ours) : null };
647
+ }
648
+
649
+ /* ---------- live bridge: a shared conversation the specialist can join ---------- */
650
+ // Append a line both the user and the specialist can see. Roles: user | ava | specialist | system.
651
+ export function pushBridge(sess, role, text) {
652
+ const b = sess.ours.bridge; if (!b || !text) return;
653
+ b.log.push({ seq: ++b.seq, role, text: String(text).slice(0, 2000), at: Date.now() });
654
+ if (b.log.length > 300) b.log.shift();
655
+ }
656
+ // Everything the specialist needs to arrive with full context (no cold start).
657
+ function bridgeContext(sess) {
658
+ const o = sess.ours;
659
+ const ss = o.screenState && !o.screenState.stale ? o.screenState : null;
660
+ return {
661
+ platform: o.platform ? { name: o.platform.name, url: o.platform.url } : null,
662
+ mode: o.mode, goal: o.goal,
663
+ screen: ss ? { app: ss.app, page: ss.page, state: ss.state || "", elements: ss.elements || [] } : null,
664
+ workflow: o.workflow ? workflowView(o) : null,
665
+ reasoning: o.lastThink || null,
666
+ handoff: o.handoff,
667
+ specialist: o.bridge.specialist,
668
+ };
669
+ }
670
+ export function bridgePoll(sess, since = 0) {
671
+ const b = sess.ours.bridge;
672
+ return { seq: b.seq, events: b.log.filter((e) => e.seq > since), context: bridgeContext(sess) };
673
+ }
674
+ export function specialistJoin(sess) {
675
+ const o = sess.ours;
676
+ if (!o.handoff.requested) return null; // the user hasn't asked for a person
677
+ o.bridge.specialist = true;
678
+ o.handoff = { requested: true, state: "joined", at: Date.now() };
679
+ pushBridge(sess, "system", "A specialist joined the call and can see your screen and progress.");
680
+ return bridgePoll(sess, 0);
681
+ }
682
+ export function specialistSay(sess, text) {
683
+ pushBridge(sess, "specialist", text);
684
+ return { ok: true, seq: sess.ours.bridge.seq };
685
+ }
686
+ export function specialistLeave(sess) {
687
+ const o = sess.ours;
688
+ o.bridge.specialist = false;
689
+ o.handoff = { requested: false, state: "none" };
690
+ revokeSpecialistKey(sess);
691
+ pushBridge(sess, "system", "The specialist left — Ava is back with you.");
692
+ return bridgePoll(sess, 0);
693
+ }
694
+ // The user speaks while a specialist is present → goes to the specialist, not the LLM.
695
+ export function userSayToBridge(sess, text) {
696
+ pushBridge(sess, "user", text);
697
+ return { ok: true, seq: sess.ours.bridge.seq };
698
+ }
699
+
700
+ // Generate ONE proactive line about the CURRENT screen (client calls this after the
701
+ // screen settles and only when Ava isn't already speaking → no overlap/flooding).
702
+ /* Silent-browsing intention guesser: watches the screen journey (activity
703
+ stream) and infers what the user is probably TRYING to do, so the goal
704
+ exists even when they never type or speak — and the talker has something
705
+ real to engage with instead of narrating screens. The deep planner still
706
+ overrides this whenever a real turn runs. */
707
+ async function guessIntention(o, usages) {
708
+ const acts = o.activity.slice(-6).map((a) => a.action).join("; ") || "—";
709
+ const messages = [
710
+ { 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
+ { role: "user", content: `Screens/actions (oldest first): ${acts}\nCurrent screen: ${screenLine(o, true)}\nPrevious guess: ${o.goal}` },
712
+ ];
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 }) });
714
+ usages.push(out);
715
+ o.lastGuessAt = Date.now();
716
+ const p = parseJSON(out.text, null);
717
+ if (p && p.goal && (p.confidence == null || p.confidence >= 0.5)) {
718
+ o.goal = p.goal; o.understanding = p.understanding || o.understanding; o.goalGuessed = true;
719
+ o.planApp = o.screenState && !o.screenState.stale ? o.screenState.app : o.planApp; // so it goes stale if they change apps
720
+ return p;
721
+ }
722
+ return null;
723
+ }
724
+
725
+ export async function proactiveNudge(sess) {
726
+ const o = sess.ours; const usages = [];
727
+ const ss = o.screenState;
728
+ const empty = { text: null, goal: o.goal, session: scoreView(sess.score.ours) };
729
+ if (!ss || ss.stale) return empty; // nothing shared → never invent
730
+ if (Date.now() - o.lastProactive < 1500) return empty; // just enough breath to not talk over herself
731
+ if (o.done && o.doneAnnounced) return empty; // task finished and already wrapped up → stay quiet, stop volunteering
732
+ // No goal yet (or a stale guess) while they browse → infer one from the journey
733
+ let guessedNow = null;
734
+ if (o.activity.length >= 2 && (o.goal === "—" || (o.goalGuessed && Date.now() - (o.lastGuessAt || 0) > 15000))) {
735
+ guessedNow = await guessIntention(o, usages);
736
+ }
737
+ const recentMine = o.history.slice(-6).filter((t) => t.role === "assistant").map((t) => t.text);
738
+ const planLine = o.plan.length ? o.plan.map((p, i) => `${i + 1}) ${p}`).join(" ") : "—";
739
+ const since = o.lastSpokeAt ? Math.round((Date.now() - o.lastSpokeAt) / 1000) + "s ago" : "a while ago";
740
+ // Ava's own vision agent flagged a reversal (they closed/cancelled/abandoned
741
+ // something you were helping with) → do not let her SKIP that moment.
742
+ const lastAct = (o.activity.at(-1) || {}).action || "";
743
+ const reversal = /clos(e|ed)|cancel|dismiss|abandon|remov|delet|went back|revert|error|fail/i.test(lastAct);
744
+ const directive = o.done
745
+ ? "\nTHEY JUST FINISHED — the whole task is COMPLETE and confirmed on screen. Say ONE short, warm congratulations that they're all set, then STOP. Do NOT give another step, suggest extra chores, or ask what's next."
746
+ : reversal
747
+ ? "\nHEADS UP: they just closed/cancelled/abandoned something you were engaged with. Follow up like a person would — do NOT reply SKIP. React (empathy or curiosity) and offer the next move."
748
+ : "";
749
+ // Once the task is done, drop the onboarding "give the next step" pressure —
750
+ // the done directive above is a wrap-up, not a nudge to keep guiding.
751
+ const momentSys = MOMENT_SYS + (!o.done && o.mode === "onboarding" && o.platform ? ONBOARDING_MOMENT : "");
752
+ const messages = [
753
+ { role: "system", content: momentSys },
754
+ { 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
+ ];
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?" });
757
+ usages.push(out); addTokens(sess, "ours", usages);
758
+ const t = sanitizeReply(stripLabel(out.text));
759
+ const base = { goal: o.goal, understanding: o.understanding, goalGuessedNow: !!guessedNow, workflow: o.workflow ? workflowView(o) : null, session: scoreView(sess.score.ours) };
760
+ if (!t || /^skip\b/i.test(t)) return { ...base, text: null };
761
+ const repeatGate = (o.mode === "onboarding" && o.platform) ? 0.8 : 0.6; // onboarding guidance reuses step language; only block near-duplicates
762
+ if (recentMine.some((l) => similar(l, t) > repeatGate)) return { ...base, text: null };
763
+ o.lastProactive = Date.now(); o.lastSpokeAt = Date.now(); remember(o, "assistant", t); pushBridge(sess, "ava", t);
764
+ if (o.done) o.doneAnnounced = true; // celebrated the finish once → go quiet from here
765
+ return { ...base, text: t };
766
+ }