diff --git a/assets/webstore-explainer-2026-2/01-hero-light.png b/assets/webstore-explainer-2026-2/01-hero-light.png new file mode 100644 index 000000000..effeb1bd3 Binary files /dev/null and b/assets/webstore-explainer-2026-2/01-hero-light.png differ diff --git a/assets/webstore-explainer-2026-2/05-plan-before-act-light.png b/assets/webstore-explainer-2026-2/05-plan-before-act-light.png new file mode 100644 index 000000000..5eb4e917d Binary files /dev/null and b/assets/webstore-explainer-2026-2/05-plan-before-act-light.png differ diff --git a/assets/webstore-explainer-2026-2/README.md b/assets/webstore-explainer-2026-2/README.md index 3ce1ac017..4b7211561 100644 --- a/assets/webstore-explainer-2026-2/README.md +++ b/assets/webstore-explainer-2026-2/README.md @@ -11,6 +11,14 @@ Files (1280×800): - 06-launch-offer.png: WebBrain Cloud $5/mo (reg. $8), Save 35% - 07-social-proof.png: 500+ GitHub stars, 20+ contributors, MIT — repo bar with contributor avatars +Light-background alternates of the two dark slides (originals kept, use whichever fits the gallery): +- 01-hero-light.png +- 05-plan-before-act-light.png + +Both come from the same `hero(light)` / `planScene(light)` functions, so edits apply to +dark and light together. In the light plan card the panel goes white and the accent green +darkens for contrast against white button text. + Star/contributor counts are hardcoded in `proofScene()`; bump them there when they go stale (544 stars at last render, 2026-07-29). The avatar initials are decorative, not real contributor handles. Slide 07 is appended after the offer to keep the existing filenames stable. If proof-then-price reads better, swap the last two entries in the `scenes` array and rename the two PNGs. diff --git a/assets/webstore-explainer-2026-2/render.mjs b/assets/webstore-explainer-2026-2/render.mjs index c9311fcfb..4057628db 100644 --- a/assets/webstore-explainer-2026-2/render.mjs +++ b/assets/webstore-explainer-2026-2/render.mjs @@ -56,6 +56,12 @@ const baseCss = ` .provider { --bg1:#f6f8fb; --bg2:#eef8f1; --ink:#182033; --muted:#5b6574; --accent:#3e6ff4; --accent2:#28a96b; --border:rgba(25,38,68,0.13); --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(24,52,90,0.19); } + .hero-light { --bg1:#f7f9ff; --bg2:#fff1f6; --ink:#141828; --muted:#586074; + --accent:#d6417f; --accent2:#12a06a; --border:rgba(28,34,64,0.13); + --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(40,44,80,0.18); } + .plan-light { --bg1:#f5fbf7; --bg2:#eef4ff; --ink:#141b26; --muted:#586374; + --accent:#12a25f; --accent2:#dd9414; --border:rgba(24,38,48,0.13); + --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(28,50,44,0.18); } .proof { --bg1:#f7f6ff; --bg2:#eef4ff; --ink:#171a2b; --muted:#5a6274; --accent:#6e56cf; --accent2:#f0a52b; --border:rgba(30,34,64,0.13); --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(38,34,78,0.18); } @@ -87,17 +93,18 @@ function brandRow(light) { `; } -/* ---------- 01 HERO ---------- */ -function hero() { +/* ---------- 01 HERO (dark + light variant) ---------- */ +function hero(light = false) { return { - file: '01-hero.png', - theme: 'dark', + file: light ? '01-hero-light.png' : '01-hero.png', + theme: light ? 'hero-light' : 'dark', body: `
- +
WebBrain

Your open-source
AI browser agent

-
+
Ask. Act. Automate. Any LLM. @@ -252,16 +259,21 @@ function modelsScene() { }; } -/* ---------- 05 PLAN BEFORE ACT ---------- */ -function planScene() { +/* ---------- 05 PLAN BEFORE ACT (dark + light variant) ---------- */ +function planScene(light = false) { const steps = [ 'Read the visible form and required fields', 'Fill only what you asked for', 'Pause before any purchase or submit', ]; + const card = light + ? { bg: '#ffffff', border: 'var(--border)', shadow: '0 30px 70px rgba(28,50,44,0.18)', + label: 'var(--muted)', step: 'var(--ink)', onAccent: '#ffffff' } + : { bg: '#151c2a', border: 'rgba(255,255,255,0.16)', shadow: '0 30px 80px rgba(0,0,0,0.42)', + label: '#8d99ad', step: '#d7e2ea', onAccent: '#102319' }; return { - file: '05-plan-before-act.png', - theme: 'plan', + file: light ? '05-plan-before-act-light.png' : '05-plan-before-act.png', + theme: light ? 'plan-light' : 'plan', body: `
${brandRow()} @@ -273,23 +285,24 @@ function planScene() {
Approve first. You stay in the loop.
-
-
Proposed browser plan
+
+
Proposed browser plan
${steps.map((label, i) => ` -
+
${i + 1} + background:var(--accent); color:${card.onAccent}; font-size:17px; font-weight:850;">${i + 1} ${label}
`).join('')}
Approve + border-radius:14px; background:var(--accent); color:${card.onAccent}; font-size:17px; font-weight:800;">Approve Adjust + border-radius:14px; border:1px solid ${light ? 'var(--border)' : 'rgba(255,255,255,0.2)'}; + background:${light ? '#f4f6fa' : 'rgba(255,255,255,0.07)'}; + color:${light ? 'var(--ink)' : '#e9eef6'}; font-size:17px; font-weight:800;">Adjust
@@ -393,7 +406,10 @@ function proofScene() { }; } -const scenes = [hero(), actScene(), askScene(), modelsScene(), planScene(), offerScene(), proofScene()]; +const scenes = [ + hero(), actScene(), askScene(), modelsScene(), planScene(), offerScene(), proofScene(), + hero(true), planScene(true), +]; function html(scene) { return ` diff --git a/assets/webstore-explainer-2026-3/01-hero-light.png b/assets/webstore-explainer-2026-3/01-hero-light.png new file mode 100644 index 000000000..52bec7896 Binary files /dev/null and b/assets/webstore-explainer-2026-3/01-hero-light.png differ diff --git a/assets/webstore-explainer-2026-3/01-hero.png b/assets/webstore-explainer-2026-3/01-hero.png new file mode 100644 index 000000000..49b0dca38 Binary files /dev/null and b/assets/webstore-explainer-2026-3/01-hero.png differ diff --git a/assets/webstore-explainer-2026-3/02-tell-the-browser.png b/assets/webstore-explainer-2026-3/02-tell-the-browser.png new file mode 100644 index 000000000..0ec127a16 Binary files /dev/null and b/assets/webstore-explainer-2026-3/02-tell-the-browser.png differ diff --git a/assets/webstore-explainer-2026-3/03-ask-any-page.png b/assets/webstore-explainer-2026-3/03-ask-any-page.png new file mode 100644 index 000000000..261ae7f25 Binary files /dev/null and b/assets/webstore-explainer-2026-3/03-ask-any-page.png differ diff --git a/assets/webstore-explainer-2026-3/04-any-llm.png b/assets/webstore-explainer-2026-3/04-any-llm.png new file mode 100644 index 000000000..e743b6761 Binary files /dev/null and b/assets/webstore-explainer-2026-3/04-any-llm.png differ diff --git a/assets/webstore-explainer-2026-3/05-plan-before-act-light.png b/assets/webstore-explainer-2026-3/05-plan-before-act-light.png new file mode 100644 index 000000000..84e24d00f Binary files /dev/null and b/assets/webstore-explainer-2026-3/05-plan-before-act-light.png differ diff --git a/assets/webstore-explainer-2026-3/05-plan-before-act.png b/assets/webstore-explainer-2026-3/05-plan-before-act.png new file mode 100644 index 000000000..26b2ba2ac Binary files /dev/null and b/assets/webstore-explainer-2026-3/05-plan-before-act.png differ diff --git a/assets/webstore-explainer-2026-3/06-launch-offer.png b/assets/webstore-explainer-2026-3/06-launch-offer.png new file mode 100644 index 000000000..f2d6e88c2 Binary files /dev/null and b/assets/webstore-explainer-2026-3/06-launch-offer.png differ diff --git a/assets/webstore-explainer-2026-3/07-social-proof.png b/assets/webstore-explainer-2026-3/07-social-proof.png new file mode 100644 index 000000000..19f4baf0b Binary files /dev/null and b/assets/webstore-explainer-2026-3/07-social-proof.png differ diff --git a/assets/webstore-explainer-2026-3/README.md b/assets/webstore-explainer-2026-3/README.md new file mode 100644 index 000000000..e4c1249b6 --- /dev/null +++ b/assets/webstore-explainer-2026-3/README.md @@ -0,0 +1,39 @@ +# Web Store explainer visuals 2026 — v3 (logo-free) + +Same gallery as v2 (`../webstore-explainer-2026-2`), with the WebBrain product icon +removed from every slide — the Web Store already shows it beside the listing. The +"WebBrain" wordmark stays as text in the corner brand row and the hero. + +Dropped in four places vs v2: the hero icon, the `.brand` row icon, the chat-input +avatar on 02, and the now-unused `assets.logo` data URI. The GitHub mark on 07 is +not ours and stays. + +v2 remains the version to edit if you want the icon back; the two are independent +copies, so a content change needs applying in both. + +Files (1280×800): +- 01-hero.png: Wordmark + tagline hero +- 02-tell-the-browser.png: Flight-search command front and center as chat input, browser acting on it +- 03-ask-any-page.png: Ask mode, cropped to the answer panel +- 04-any-llm.png: Model picker, cropped to the provider dropdown +- 05-plan-before-act.png: Plan review with Approve/Adjust before actions run +- 06-launch-offer.png: WebBrain Cloud $5/mo (reg. $8), Save 35% +- 07-social-proof.png: 500+ GitHub stars, 20+ contributors, MIT — repo bar with contributor avatars + +Light-background alternates of the two dark slides (originals kept, use whichever fits the gallery): +- 01-hero-light.png +- 05-plan-before-act-light.png + +Both come from the same `hero(light)` / `planScene(light)` functions, so edits apply to +dark and light together. In the light plan card the panel goes white and the accent green +darkens for contrast against white button text. + +Star/contributor counts are hardcoded in `proofScene()`; bump them there when they go stale (544 stars at last render, 2026-07-29). The avatar initials are decorative, not real contributor handles. + +Slide 07 is appended after the offer to keep the existing filenames stable. If proof-then-price reads better, swap the last two entries in the `scenes` array and rename the two PNGs. + +Regenerate: + +```bash +node assets/webstore-explainer-2026-3/render.mjs +``` diff --git a/assets/webstore-explainer-2026-3/render.mjs b/assets/webstore-explainer-2026-3/render.mjs new file mode 100644 index 000000000..f02bdd2d0 --- /dev/null +++ b/assets/webstore-explainer-2026-3/render.mjs @@ -0,0 +1,435 @@ +import { chromium } from 'playwright'; +import { readFileSync } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const DIR = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(DIR, '../..'); + +// No product logo in this variant — the Web Store already shows the icon beside the listing. +const assets = { + ask: dataUri('assets/screenshot-1-ask-mode.png'), + models: dataUri('assets/screenshots_v1/s7-model-selector.png'), +}; + +function dataUri(relativePath) { + const bytes = readFileSync(path.join(ROOT, relativePath)); + return `data:image/png;base64,${bytes.toString('base64')}`; +} + +const W = 1280; +const H = 800; + +const baseCss = ` + :root { color-scheme: light; } + * { box-sizing: border-box; } + html, body { + margin: 0; width: ${W}px; height: ${H}px; overflow: hidden; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + } + .canvas { + position: relative; width: ${W}px; height: ${H}px; overflow: hidden; + background: linear-gradient(135deg, var(--bg1), var(--bg2)); + color: var(--ink); isolation: isolate; + } + .canvas:after { + content: ""; position: absolute; z-index: 0; pointer-events: none; + right: -120px; bottom: -140px; width: 540px; height: 360px; + background: + linear-gradient(90deg, transparent 0 28px, rgba(255,255,255,0.14) 28px 30px, transparent 30px 72px), + linear-gradient(0deg, transparent 0 28px, rgba(255,255,255,0.12) 28px 30px, transparent 30px 72px); + transform: rotate(-8deg); opacity: 0.8; + } + .dark { --bg1:#121525; --bg2:#243044; --ink:#ffffff; --muted:#d5d9e3; + --accent:#f56ca8; --accent2:#65d69d; --border:rgba(255,255,255,0.16); + --panel:rgba(255,255,255,0.08); --shadow:0 28px 70px rgba(0,0,0,0.32); } + .act { --bg1:#f7fbff; --bg2:#fff4ef; --ink:#182033; --muted:#596473; + --accent:#df573f; --accent2:#4d7df5; --border:rgba(31,40,64,0.14); + --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(68,50,40,0.20); } + .read { --bg1:#fffaf3; --bg2:#eef5ff; --ink:#171827; --muted:#565e70; + --accent:#6757ff; --accent2:#ff6f9d; --border:rgba(37,42,66,0.12); + --panel:rgba(255,255,255,0.88); --shadow:0 28px 70px rgba(31,35,62,0.20); } + .plan { --bg1:#111827; --bg2:#223040; --ink:#f8fafc; --muted:#c7d2de; + --accent:#45d483; --accent2:#ffc857; --border:rgba(255,255,255,0.18); + --panel:rgba(255,255,255,0.08); --shadow:0 28px 80px rgba(0,0,0,0.34); } + .provider { --bg1:#f6f8fb; --bg2:#eef8f1; --ink:#182033; --muted:#5b6574; + --accent:#3e6ff4; --accent2:#28a96b; --border:rgba(25,38,68,0.13); + --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(24,52,90,0.19); } + .hero-light { --bg1:#f7f9ff; --bg2:#fff1f6; --ink:#141828; --muted:#586074; + --accent:#d6417f; --accent2:#12a06a; --border:rgba(28,34,64,0.13); + --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(40,44,80,0.18); } + .plan-light { --bg1:#f5fbf7; --bg2:#eef4ff; --ink:#141b26; --muted:#586374; + --accent:#12a25f; --accent2:#dd9414; --border:rgba(24,38,48,0.13); + --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(28,50,44,0.18); } + .proof { --bg1:#f7f6ff; --bg2:#eef4ff; --ink:#171a2b; --muted:#5a6274; + --accent:#6e56cf; --accent2:#f0a52b; --border:rgba(30,34,64,0.13); + --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(38,34,78,0.18); } + .content { position: relative; z-index: 1; width: 100%; height: 100%; padding: 44px 56px; } + .brand { display: flex; align-items: center; gap: 13px; font-size: 22px; font-weight: 760; } + .eyebrow { + display: inline-flex; align-items: center; gap: 10px; padding: 9px 14px; + color: var(--accent); background: var(--panel); border: 1px solid var(--border); + border-radius: 999px; font-size: 14px; font-weight: 780; text-transform: uppercase; + letter-spacing: 0.06em; line-height: 1; + } + .eyebrow .dot { width: 9px; height: 9px; border-radius: 999px; background: var(--accent2); + box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent2) 18%, transparent); } + h1 { margin: 18px 0 0; font-size: 60px; line-height: 1.04; font-weight: 830; text-wrap: balance; } + .sub { margin-top: 14px; color: var(--muted); font-size: 24px; font-weight: 540; } + .crop-frame { + overflow: hidden; border: 1px solid var(--border); border-radius: 24px; + background: #ffffff; box-shadow: var(--shadow); position: relative; + } + .crop-frame img { display: block; } +`; + +function brandRow() { + return ` +
+ WebBrain +
`; +} + +/* ---------- 01 HERO (dark + light variant) ---------- */ +function hero(light = false) { + return { + file: light ? '01-hero-light.png' : '01-hero.png', + theme: light ? 'hero-light' : 'dark', + body: ` +
+
WebBrain
+

Your open-source
AI browser agent

+
+ Ask. + Act. + Automate. Any LLM. +
+
+ ${['Chromium browsers and Firefox', 'Local or cloud models', 'MIT licensed'].map((c) => ` + ${c}`).join('')} +
+
`, + }; +} + +/* ---------- 02 TELL THE BROWSER (command front and center) ---------- */ +function actScene() { + const steps = [ + ['done', 'Found the flight search form'], + ['done', 'Typed “Istanbul (IST)”'], + ['done', 'Typed “San Francisco (SFO)”'], + ['live', 'Clicking Search…'], + ]; + return { + file: '02-tell-the-browser.png', + theme: 'act', + body: ` +
+ ${brandRow()} + Act mode +
+
+

Tell the browser what to do.

+
+ + +
+
+ Search for the cheapest flights from Istanbul to San Francisco +
+
+
+ + +
+
+
+ + + + google.com/travel/flights +
+
+
Google Flights
+
+
Istanbul (IST)
+
+
San Francisco (SFO)
+
+
+
Search
+ + + +
+
+
+ + $612 +
+
+ + $688 +
+
+
+
+
+
WebBrain is acting
+
+ ${steps.map(([state, label]) => ` +
+ ${state === 'live' ? '●' : '✓'} + ${label} +
`).join('')} +
+
+ Pauses before anything irreversible +
+
+
`, + }; +} + +/* ---------- 03 ASK ANY PAGE (cropped to the answer panel) ---------- */ +function askScene() { + // Source 1280x800. Relevant UI: sidebar below header x 905-1280, y 122-474. Scale 1.62. + const s = 1.62; + const x = 906, y = 122, w = 374, h = 352; + return { + file: '03-ask-any-page.png', + theme: 'read', + body: ` +
+ ${brandRow()} + Ask mode +
+
+
+

Ask any page.
Get the useful part.

+
Clean answers from messy pages. Read-only by default.
+
+
+
+ +
+
+
`, + }; +} + +/* ---------- 04 ANY LLM (cropped to model dropdown) ---------- */ +function modelsScene() { + // Source 1280x800. Relevant UI: dropdown+header x 418-1162, y 6-748. Scale 0.82. + const s = 0.82; + const x = 418, y = 6, w = 744, h = 742; + return { + file: '04-any-llm.png', + theme: 'provider', + body: ` +
+ ${brandRow()} + Any LLM +
+
+
+

Use the model you trust.

+
Local, cloud, or your own keys — switch anytime.
+
+
+
+ +
+
+
`, + }; +} + +/* ---------- 05 PLAN BEFORE ACT (dark + light variant) ---------- */ +function planScene(light = false) { + const steps = [ + 'Read the visible form and required fields', + 'Fill only what you asked for', + 'Pause before any purchase or submit', + ]; + const card = light + ? { bg: '#ffffff', border: 'var(--border)', shadow: '0 30px 70px rgba(28,50,44,0.18)', + label: 'var(--muted)', step: 'var(--ink)', onAccent: '#ffffff' } + : { bg: '#151c2a', border: 'rgba(255,255,255,0.16)', shadow: '0 30px 80px rgba(0,0,0,0.42)', + label: '#8d99ad', step: '#d7e2ea', onAccent: '#102319' }; + return { + file: light ? '05-plan-before-act-light.png' : '05-plan-before-act.png', + theme: light ? 'plan-light' : 'plan', + body: ` +
+ ${brandRow()} + Plan before Act +
+
+
+

See the plan before it touches the page.

+
Approve first. You stay in the loop.
+
+
+
+
Proposed browser plan
+
+ ${steps.map((label, i) => ` +
+ ${i + 1} + ${label} +
`).join('')} +
+
+ Approve + Adjust +
+
+
+
`, + }; +} + +/* ---------- 06 LAUNCH OFFER ---------- */ +function offerScene() { + return { + file: '06-launch-offer.png', + theme: 'dark', + body: ` +
+ ${brandRow()} + Launch offer +
+
+

WebBrain Cloud launch pricing

+
+ $8 + $5/mo + Save 35% +
+
No setup, no API keys — just install and go.
+
Or free forever with your own keys or local models.
+
`, + }; +} + +/* ---------- 07 SOCIAL PROOF ---------- */ +function proofScene() { + const icon = (paths, color) => + ``; + const stats = [ + [icon('', 'var(--accent2)'), + '500+', 'GitHub stars'], + [icon('', 'var(--accent)'), + '20+', 'contributors'], + [icon('', 'var(--accent)'), + 'MIT', 'licensed, free forever'], + ]; + // Decorative contributor avatars: initials over a fixed palette. + const avatars = [ + ['ES', '#6e56cf'], ['MK', '#e0644f'], ['AY', '#2b9f6b'], ['JR', '#2f6fe0'], + ['LP', '#c8478f'], ['DT', '#e08b2b'], ['SN', '#4a5568'], ['KV', '#0f9aa8'], + ['BW', '#8b5cf6'], ['RO', '#d14f6d'], + ]; + return { + file: '07-social-proof.png', + theme: 'proof', + body: ` +
+ ${brandRow()} + Open source +
+ +
+

Built in the open.

+
+ +
+ ${stats.map(([glyph, value, label]) => ` +
+
${glyph}
+
${value}
+
${label}
+
`).join('')} +
+ +
+
+ +
+
github.com/webbrain-one/webbrain
+
Star it, fork it, ship a PR.
+
+
+
+
+ ${avatars.map(([initials, bg], i) => ` + ${initials}`).join('')} + +10 +
+ + Star +
+
`, + }; +} + +const scenes = [ + hero(), actScene(), askScene(), modelsScene(), planScene(), offerScene(), proofScene(), + hero(true), planScene(true), +]; + +function html(scene) { + return ` + +
${scene.body}
`; +} + +async function renderAll() { + await mkdir(DIR, { recursive: true }); + const browser = await chromium.launch(); + for (const scene of scenes) { + const page = await browser.newPage({ viewport: { width: W, height: H }, deviceScaleFactor: 1 }); + await page.setContent(html(scene), { waitUntil: 'load' }); + await page.evaluate(async () => { + await Promise.all(Array.from(document.images).map((img) => img.complete ? undefined : new Promise((res, rej) => { + img.addEventListener('load', res, { once: true }); + img.addEventListener('error', rej, { once: true }); + }))); + await document.fonts.ready; + }); + await page.screenshot({ path: path.join(DIR, scene.file) }); + await page.close(); + console.log('rendered', scene.file); + } + await browser.close(); +} + +renderAll().catch((error) => { console.error(error); process.exit(1); }); diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 6e0d801f8..490bb1b14 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -10,7 +10,7 @@ import { detectProgressAction, formatLedgerRow, formatLedgerSummary, isBlockedLe import { buildGithubStargazerProgressItems } from './observers/github-stargazers.js'; import { analyzeMastodonPage, mastodonHandoffInstruction, mastodonProgressGuard } from './observers/mastodon.js'; import { isProgressActionAllowed, isProgressIntentActive, normalizeProgressAction, normalizeProgressIntent } from './progress-intent.js'; -import { completionDoneBlock, completionPlainFinalBlock, consumeCompletionObservation, consumeCompletionObservationResult, createCompletionInvariantState, hasUnconsumedCompletionObservation, hasUnconsumedCompletionObservationResult, recordCompletionToolResult } from './completion-invariant.js'; +import { classifyCompletionForm, completionDoneBlock, completionPlainFinalBlock, consumeCompletionObservation, consumeCompletionObservationResult, createCompletionInvariantState, hasUnconsumedCompletionObservation, hasUnconsumedCompletionObservationResult, recordCompletionToolResult } from './completion-invariant.js'; import { cdpClient } from '../cdp/cdp-client.js'; import { getActiveAdapter, getFullPageCapturePolicy, UNIVERSAL_PREAMBLE } from './adapters.js'; import { @@ -659,6 +659,7 @@ export class Agent extends LoopDetector { label: boundedText(form?.label, 80), relevant: !!form?.relevant, utility: !!form?.utility, + utilityReason: boundedText(form?.utilityReason, 40), editableCount: Number(form?.editableCount || 0), submitCount: Number(form?.submitCount || 0), })) @@ -14973,18 +14974,36 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return true; } catch (e) { return false; } } + const classifyForm = ${classifyCompletionForm.toString()}; const dialogs = Array.from(document.querySelectorAll('[role=dialog],[role=alertdialog],[aria-modal="true"],dialog[open]')).filter(visible); const forms = Array.from(document.querySelectorAll('form')).filter(visible); + const primaryContent = document.querySelector('main,[role=main]'); const formDescriptors = forms.map(form => { const editable = Array.from(form.querySelectorAll('input:not([type=hidden]):not([type=button]):not([type=submit]):not([type=reset]):not([type=image]),textarea,select,[contenteditable=true]')).filter(visible); const submits = Array.from(form.querySelectorAll('button,input[type=submit],input[type=image],[role=button]')).filter(visible); const utilityRegion = !!form.closest('header,nav,footer,[role=banner],[role=navigation],[role=search],[role=contentinfo]') || form.getAttribute('role') === 'search'; - const searchOnly = editable.length > 0 - && editable.every(el => el.matches('input[type=search]') || /^(q|query|search|filter)$/i.test(el.getAttribute('name') || '')) - && submits.every(el => /search|filter|go/i.test((el.innerText || el.value || el.getAttribute('aria-label') || '').trim())); const label = (form.getAttribute('aria-label') || form.getAttribute('name') || form.id || (form.querySelector('h1,h2,h3,legend')?.innerText || '')).trim().slice(0, 80); - const relevant = !utilityRegion && !searchOnly && (editable.length > 0 || submits.length > 0); - return { label, relevant, utility: utilityRegion || searchOnly, editableCount: editable.length, submitCount: submits.length }; + return classifyForm({ + label, + utilityRegion, + outsidePrimaryContent: !!primaryContent && !form.closest('main,[role=main]'), + insideDialog: !!form.closest('[role=dialog],[role=alertdialog],[aria-modal=true],dialog[open]'), + method: form.getAttribute('method') || 'get', + hiddenNamedCount: Array.from(form.querySelectorAll('input[type=hidden][name]')) + .filter(el => (el.getAttribute('name') || '').trim()).length, + editable: editable.map(el => ({ + tag: (el.tagName || '').toLowerCase(), + type: el.getAttribute('type') || '', + role: el.getAttribute('role') || '', + name: el.getAttribute('name') || '', + value: 'value' in el ? el.value : (el.innerText || ''), + required: el.required === true || el.getAttribute('aria-required') === 'true', + focused: document.activeElement === el, + })), + submits: submits.map(el => ({ + label: (el.innerText || el.value || el.getAttribute('aria-label') || '').trim(), + })), + }); }); const toasts = Array.from(document.querySelectorAll('[role=status],[role=alert],[aria-live]')) .filter(visible) diff --git a/src/chrome/src/agent/completion-invariant.js b/src/chrome/src/agent/completion-invariant.js index 6e1da8ef9..83ab4ea71 100644 --- a/src/chrome/src/agent/completion-invariant.js +++ b/src/chrome/src/agent/completion-invariant.js @@ -94,6 +94,101 @@ function isSelfVerifyingActionResult(name, result) { ); } +/** + * Classify one visible form from browser-neutral structural facts. + * + * Completion probes run inside the page, so Agent serializes this function + * with toString() and supplies plain facts rather than DOM nodes. Keep this + * function self-contained and language-independent for structural fallbacks. + */ +export function classifyCompletionForm({ + label = '', + utilityRegion = false, + outsidePrimaryContent = false, + insideDialog = false, + method = 'get', + hiddenNamedCount = 0, + editable = [], + submits = [], +} = {}) { + const fields = Array.isArray(editable) ? editable : []; + const submitControls = Array.isArray(submits) ? submits : []; + const normalizedMethod = String(method || 'get').trim().toLowerCase() || 'get'; + const normalizedFields = fields.map(field => ({ + tag: String(field?.tag || '').trim().toLowerCase(), + type: String(field?.type || '').trim().toLowerCase(), + role: String(field?.role || '').trim().toLowerCase(), + name: String(field?.name || '').trim(), + value: String(field?.value || '').trim(), + required: field?.required === true, + focused: field?.focused === true, + })); + const normalizedSubmits = submitControls.map(control => ({ + label: String(control?.label || '').trim(), + })); + + const semanticSearchField = field => field.type === 'search' || field.role === 'searchbox'; + const conventionalSearchField = field => /^(q|query|search|filter)$/i.test(field.name); + // Preserve the previous per-field semantic-or-conventional behavior so a + // search input plus a named filter control remains utility UI. Task evidence + // gates the whole shortcut: semantic markup alone must not hide an assignee + // picker, dialog, POST form, or non-search submission. A retained value is + // normal search-results state and is not task evidence by itself. + const searchFieldsOnly = normalizedFields.length > 0 + && normalizedFields.every(field => semanticSearchField(field) || conventionalSearchField(field)); + const searchSubmitsOnly = normalizedSubmits.every(control => /search|filter|go/i.test(control.label)); + const searchHasTaskEvidence = !!( + insideDialog + || normalizedMethod !== 'get' + || Number(hiddenNamedCount || 0) !== 0 + || !searchSubmitsOnly + || normalizedFields.some(field => ( + field.required + || field.focused + || (!!field.name && !conventionalSearchField(field)) + )) + ); + // Primary content alone is not task evidence: result pages commonly place + // genuine search/filter forms there. + const safeSearchOnly = searchFieldsOnly && !searchHasTaskEvidence; + const hasSemanticSearchField = normalizedFields.some(semanticSearchField); + + const onlyField = normalizedFields.length === 1 ? normalizedFields[0] : null; + const passiveUtilityShell = !!( + onlyField + && outsidePrimaryContent + && !insideDialog + && normalizedMethod === 'get' + && Number(hiddenNamedCount || 0) === 0 + && normalizedSubmits.length === 0 + && onlyField.tag === 'input' + && (onlyField.type === '' || onlyField.type === 'text' || onlyField.type === 'search') + && !onlyField.name + && !onlyField.value + && !onlyField.required + && !onlyField.focused + ); + + const utilityReason = utilityRegion + ? 'utility_region' + : safeSearchOnly + ? hasSemanticSearchField + ? 'semantic_search' + : 'conventional_search' + : passiveUtilityShell + ? 'passive_utility_shell' + : null; + const utility = utilityReason !== null; + return { + label: String(label || '').trim().slice(0, 80), + relevant: !utility && (normalizedFields.length > 0 || normalizedSubmits.length > 0), + utility, + utilityReason, + editableCount: normalizedFields.length, + submitCount: normalizedSubmits.length, + }; +} + export function isCompletionActionTool(name, args = {}) { if (name === 'execute_webmcp_tool') return true; if ( diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index b3c998377..0a67c0081 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -10,7 +10,7 @@ import { detectProgressAction, formatLedgerRow, formatLedgerSummary, isBlockedLe import { buildGithubStargazerProgressItems } from './observers/github-stargazers.js'; import { analyzeMastodonPage, mastodonHandoffInstruction, mastodonProgressGuard } from './observers/mastodon.js'; import { isProgressActionAllowed, isProgressIntentActive, normalizeProgressAction, normalizeProgressIntent } from './progress-intent.js'; -import { completionDoneBlock, completionPlainFinalBlock, consumeCompletionObservation, consumeCompletionObservationResult, createCompletionInvariantState, hasUnconsumedCompletionObservation, hasUnconsumedCompletionObservationResult, recordCompletionToolResult } from './completion-invariant.js'; +import { classifyCompletionForm, completionDoneBlock, completionPlainFinalBlock, consumeCompletionObservation, consumeCompletionObservationResult, createCompletionInvariantState, hasUnconsumedCompletionObservation, hasUnconsumedCompletionObservationResult, recordCompletionToolResult } from './completion-invariant.js'; import { getActiveAdapter, UNIVERSAL_PREAMBLE } from './adapters.js'; import { fetchUrl, @@ -594,6 +594,7 @@ export class Agent extends LoopDetector { label: boundedText(form?.label, 80), relevant: !!form?.relevant, utility: !!form?.utility, + utilityReason: boundedText(form?.utilityReason, 40), editableCount: Number(form?.editableCount || 0), submitCount: Number(form?.submitCount || 0), })) @@ -12841,18 +12842,36 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return true; } catch (e) { return false; } } + const classifyForm = ${classifyCompletionForm.toString()}; const dialogs = Array.from(document.querySelectorAll('[role=dialog],[role=alertdialog],[aria-modal="true"],dialog[open]')).filter(visible); const forms = Array.from(document.querySelectorAll('form')).filter(visible); + const primaryContent = document.querySelector('main,[role=main]'); const formDescriptors = forms.map(form => { const editable = Array.from(form.querySelectorAll('input:not([type=hidden]):not([type=button]):not([type=submit]):not([type=reset]):not([type=image]),textarea,select,[contenteditable=true]')).filter(visible); const submits = Array.from(form.querySelectorAll('button,input[type=submit],input[type=image],[role=button]')).filter(visible); const utilityRegion = !!form.closest('header,nav,footer,[role=banner],[role=navigation],[role=search],[role=contentinfo]') || form.getAttribute('role') === 'search'; - const searchOnly = editable.length > 0 - && editable.every(el => el.matches('input[type=search]') || /^(q|query|search|filter)$/i.test(el.getAttribute('name') || '')) - && submits.every(el => /search|filter|go/i.test((el.innerText || el.value || el.getAttribute('aria-label') || '').trim())); const label = (form.getAttribute('aria-label') || form.getAttribute('name') || form.id || (form.querySelector('h1,h2,h3,legend')?.innerText || '')).trim().slice(0, 80); - const relevant = !utilityRegion && !searchOnly && (editable.length > 0 || submits.length > 0); - return { label, relevant, utility: utilityRegion || searchOnly, editableCount: editable.length, submitCount: submits.length }; + return classifyForm({ + label, + utilityRegion, + outsidePrimaryContent: !!primaryContent && !form.closest('main,[role=main]'), + insideDialog: !!form.closest('[role=dialog],[role=alertdialog],[aria-modal=true],dialog[open]'), + method: form.getAttribute('method') || 'get', + hiddenNamedCount: Array.from(form.querySelectorAll('input[type=hidden][name]')) + .filter(el => (el.getAttribute('name') || '').trim()).length, + editable: editable.map(el => ({ + tag: (el.tagName || '').toLowerCase(), + type: el.getAttribute('type') || '', + role: el.getAttribute('role') || '', + name: el.getAttribute('name') || '', + value: 'value' in el ? el.value : (el.innerText || ''), + required: el.required === true || el.getAttribute('aria-required') === 'true', + focused: document.activeElement === el, + })), + submits: submits.map(el => ({ + label: (el.innerText || el.value || el.getAttribute('aria-label') || '').trim(), + })), + }); }); const toasts = Array.from(document.querySelectorAll('[role=status],[role=alert],[aria-live]')) .filter(visible) diff --git a/src/firefox/src/agent/completion-invariant.js b/src/firefox/src/agent/completion-invariant.js index 6e1da8ef9..83ab4ea71 100644 --- a/src/firefox/src/agent/completion-invariant.js +++ b/src/firefox/src/agent/completion-invariant.js @@ -94,6 +94,101 @@ function isSelfVerifyingActionResult(name, result) { ); } +/** + * Classify one visible form from browser-neutral structural facts. + * + * Completion probes run inside the page, so Agent serializes this function + * with toString() and supplies plain facts rather than DOM nodes. Keep this + * function self-contained and language-independent for structural fallbacks. + */ +export function classifyCompletionForm({ + label = '', + utilityRegion = false, + outsidePrimaryContent = false, + insideDialog = false, + method = 'get', + hiddenNamedCount = 0, + editable = [], + submits = [], +} = {}) { + const fields = Array.isArray(editable) ? editable : []; + const submitControls = Array.isArray(submits) ? submits : []; + const normalizedMethod = String(method || 'get').trim().toLowerCase() || 'get'; + const normalizedFields = fields.map(field => ({ + tag: String(field?.tag || '').trim().toLowerCase(), + type: String(field?.type || '').trim().toLowerCase(), + role: String(field?.role || '').trim().toLowerCase(), + name: String(field?.name || '').trim(), + value: String(field?.value || '').trim(), + required: field?.required === true, + focused: field?.focused === true, + })); + const normalizedSubmits = submitControls.map(control => ({ + label: String(control?.label || '').trim(), + })); + + const semanticSearchField = field => field.type === 'search' || field.role === 'searchbox'; + const conventionalSearchField = field => /^(q|query|search|filter)$/i.test(field.name); + // Preserve the previous per-field semantic-or-conventional behavior so a + // search input plus a named filter control remains utility UI. Task evidence + // gates the whole shortcut: semantic markup alone must not hide an assignee + // picker, dialog, POST form, or non-search submission. A retained value is + // normal search-results state and is not task evidence by itself. + const searchFieldsOnly = normalizedFields.length > 0 + && normalizedFields.every(field => semanticSearchField(field) || conventionalSearchField(field)); + const searchSubmitsOnly = normalizedSubmits.every(control => /search|filter|go/i.test(control.label)); + const searchHasTaskEvidence = !!( + insideDialog + || normalizedMethod !== 'get' + || Number(hiddenNamedCount || 0) !== 0 + || !searchSubmitsOnly + || normalizedFields.some(field => ( + field.required + || field.focused + || (!!field.name && !conventionalSearchField(field)) + )) + ); + // Primary content alone is not task evidence: result pages commonly place + // genuine search/filter forms there. + const safeSearchOnly = searchFieldsOnly && !searchHasTaskEvidence; + const hasSemanticSearchField = normalizedFields.some(semanticSearchField); + + const onlyField = normalizedFields.length === 1 ? normalizedFields[0] : null; + const passiveUtilityShell = !!( + onlyField + && outsidePrimaryContent + && !insideDialog + && normalizedMethod === 'get' + && Number(hiddenNamedCount || 0) === 0 + && normalizedSubmits.length === 0 + && onlyField.tag === 'input' + && (onlyField.type === '' || onlyField.type === 'text' || onlyField.type === 'search') + && !onlyField.name + && !onlyField.value + && !onlyField.required + && !onlyField.focused + ); + + const utilityReason = utilityRegion + ? 'utility_region' + : safeSearchOnly + ? hasSemanticSearchField + ? 'semantic_search' + : 'conventional_search' + : passiveUtilityShell + ? 'passive_utility_shell' + : null; + const utility = utilityReason !== null; + return { + label: String(label || '').trim().slice(0, 80), + relevant: !utility && (normalizedFields.length > 0 || normalizedSubmits.length > 0), + utility, + utilityReason, + editableCount: normalizedFields.length, + submitCount: normalizedSubmits.length, + }; +} + export function isCompletionActionTool(name, args = {}) { if (name === 'execute_webmcp_tool') return true; if ( diff --git a/test/run.js b/test/run.js index 7dc1cd55b..a86911a84 100644 --- a/test/run.js +++ b/test/run.js @@ -10108,6 +10108,157 @@ test('getToolsForMode: `done` outcome is required in every supported Act and Dev } }); +test('completion form classification ignores passive localized utility shells without weakening real form guards', () => { + const passiveSidebarSearch = { + label: '', + outsidePrimaryContent: true, + insideDialog: false, + method: 'get', + hiddenNamedCount: 0, + editable: [{ + tag: 'input', + type: 'text', + role: '', + name: '', + value: '', + required: false, + focused: false, + }], + submits: [], + }; + + for (const [label, invariant, agentRel] of [ + ['chrome', CompletionInvariantCh, 'src/chrome/src/agent/agent.js'], + ['firefox', CompletionInvariantFx, 'src/firefox/src/agent/agent.js'], + ]) { + const classify = invariant.classifyCompletionForm; + const reconstructed = Function(`return (${classify.toString()});`)(); + assert.deepEqual( + reconstructed(passiveSidebarSearch), + classify(passiveSidebarSearch), + `${label}: completion form classifier is not self-contained for page injection`, + ); + assert.deepEqual( + classify(passiveSidebarSearch), + { + label: '', + relevant: false, + utility: true, + utilityReason: 'passive_utility_shell', + editableCount: 1, + submitCount: 0, + }, + `${label}: Mastodon-shaped localized sidebar search still blocked completion`, + ); + assert.deepEqual( + classify({ + editable: [{ tag: 'input', type: 'search', role: '', name: '', value: '' }], + submits: [], + }), + { + label: '', + relevant: false, + utility: true, + utilityReason: 'semantic_search', + editableCount: 1, + submitCount: 0, + }, + `${label}: semantic search input without a submit control was not recognized structurally`, + ); + assert.deepEqual( + classify({ + outsidePrimaryContent: false, + editable: [ + { tag: 'input', type: 'search', role: '', name: '', value: '' }, + { tag: 'select', type: '', role: '', name: 'filter', value: '' }, + ], + submits: [{ label: 'Filter' }], + }), + { + label: '', + relevant: false, + utility: true, + utilityReason: 'semantic_search', + editableCount: 2, + submitCount: 1, + }, + `${label}: mixed semantic and conventional search fields regressed`, + ); + assert.deepEqual( + classify({ + outsidePrimaryContent: false, + editable: [ + { tag: 'input', type: 'search', role: '', name: 'q', value: 'webbrain' }, + { tag: 'select', type: '', role: '', name: 'filter', value: 'recent' }, + ], + submits: [{ label: 'Search' }], + }), + { + label: '', + relevant: false, + utility: true, + utilityReason: 'semantic_search', + editableCount: 2, + submitCount: 1, + }, + `${label}: retained search query and filter values were treated as task drafts`, + ); + + for (const [caseName, override] of [ + ['primary-content form', { outsidePrimaryContent: false }], + ['dialog form', { insideDialog: true }], + ['POST form', { method: 'post' }], + ['form with hidden submitted state', { hiddenNamedCount: 1 }], + ['required field', { editable: [{ ...passiveSidebarSearch.editable[0], required: true }] }], + ['focused field', { editable: [{ ...passiveSidebarSearch.editable[0], focused: true }] }], + ['draft-bearing field', { editable: [{ ...passiveSidebarSearch.editable[0], value: 'draft' }] }], + ['named task field', { editable: [{ ...passiveSidebarSearch.editable[0], name: 'title' }] }], + ['form with a submit control', { submits: [{ label: 'Gönder' }] }], + ]) { + const result = classify({ ...passiveSidebarSearch, ...override }); + assert.equal(result.relevant, true, `${label}: ${caseName} was hidden as utility UI`); + assert.equal(result.utility, false, `${label}: ${caseName} received a utility classification`); + } + + const semanticTaskSelector = { + outsidePrimaryContent: true, + insideDialog: false, + method: 'get', + hiddenNamedCount: 0, + editable: [{ + tag: 'input', + type: 'search', + role: 'searchbox', + name: '', + value: '', + required: false, + focused: false, + }], + submits: [], + }; + for (const [caseName, override] of [ + ['dialog search selector', { insideDialog: true }], + ['POST search selector', { method: 'post' }], + ['search selector with hidden submitted state', { hiddenNamedCount: 1 }], + ['required search selector', { editable: [{ ...semanticTaskSelector.editable[0], required: true }] }], + ['focused search selector', { editable: [{ ...semanticTaskSelector.editable[0], focused: true }] }], + ['named task search selector', { editable: [{ ...semanticTaskSelector.editable[0], name: 'assignee' }] }], + ['search selector with non-search submit', { submits: [{ label: 'Add' }] }], + ]) { + const result = classify({ ...semanticTaskSelector, ...override }); + assert.equal(result.relevant, true, `${label}: ${caseName} was hidden as semantic search UI`); + assert.equal(result.utility, false, `${label}: ${caseName} received a semantic utility classification`); + } + + const agentSource = fs.readFileSync(path.join(ROOT, agentRel), 'utf8'); + assert.match( + agentSource, + /const classifyForm = \$\{classifyCompletionForm\.toString\(\)\}/, + `${label}: done verification probe is not wired to the shared form classifier`, + ); + } +}); + test('completion invariant state machine enforces post-action observation with Chrome/Firefox parity', () => { assert.equal( fs.readFileSync(path.join(ROOT, 'src/chrome/src/agent/completion-invariant.js'), 'utf8'),