@fastbrowse/darwin-arm64 0.5.19 → 0.5.20

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 (18) hide show
  1. package/fastbrowse/_internal/base_library.zip +0 -0
  2. package/fastbrowse/_internal/fastbrowse/browser/capture.js +122 -17
  3. package/fastbrowse/_internal/fastbrowse/browser/snapshot.js +72 -13
  4. package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.20.dist-info}/METADATA +47 -51
  5. package/fastbrowse/_internal/fastbrowse-0.5.20.dist-info/RECORD +14 -0
  6. package/fastbrowse/_internal/fastbrowse-0.5.20.dist-info/uv_cache.json +1 -0
  7. package/fastbrowse/fastbrowse +0 -0
  8. package/package.json +1 -1
  9. package/fastbrowse/_internal/fastbrowse-0.5.19.dist-info/RECORD +0 -14
  10. package/fastbrowse/_internal/fastbrowse-0.5.19.dist-info/uv_cache.json +0 -1
  11. /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.20.dist-info}/INSTALLER +0 -0
  12. /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.20.dist-info}/REQUESTED +0 -0
  13. /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.20.dist-info}/WHEEL +0 -0
  14. /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.20.dist-info}/direct_url.json +0 -0
  15. /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.20.dist-info}/entry_points.txt +0 -0
  16. /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.20.dist-info}/licenses/LICENSE +0 -0
  17. /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.20.dist-info}/licenses/NOTICE +0 -0
  18. /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.20.dist-info}/uv_build.json +0 -0
@@ -2,8 +2,8 @@
2
2
  // Unlike snapshot.js (bounded, for Jev's action choice) this reads the whole rendered document, on screen or not,
3
3
  // so the reading path and answer-evidence quotes see everything a user could scroll to.
4
4
  // Adjacent inline content (text, <strong>, <a>, <span>...) is one block, so a value inside markup stays with its label.
5
- // biome-ignore format: Python embeds this IIFE inside an expression, where a semicolon is invalid.
6
- (() => {
5
+ // biome-ignore format: Python embeds this function inside an expression, where a semicolon is invalid.
6
+ ((allowedOrigins = null) => {
7
7
  if (!document.body) return null;
8
8
  const HEADINGS = { H1: 1, H2: 2, H3: 3, H4: 4, H5: 5, H6: 6 };
9
9
  const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'CANVAS', 'IFRAME']);
@@ -12,7 +12,32 @@
12
12
  let framePath = null;
13
13
  let sourcePath = '';
14
14
  let inaccessible = 0;
15
+ let visibleH1 = false;
16
+ let controlObservations = 0;
17
+ let imageObservations = 0;
18
+ let imagesOmitted = false;
19
+ let controlsOmitted = false;
20
+ const imageSource = source => {
21
+ if (!source) return source;
22
+ if (source.startsWith('data:')) {
23
+ const comma = source.indexOf(',');
24
+ return `${source.slice(0, comma < 0 ? 128 : Math.min(comma, 128))},[payload omitted]`;
25
+ }
26
+ if (source.startsWith('blob:')) return 'blob:[identifier omitted]';
27
+ return source.length > 1024 ? `${source.slice(0, 1024)}[truncated]` : source;
28
+ };
15
29
  const registry = (window.__fastbrowse ||= { ids: new WeakMap(), nodes: new Map(), next: 1 });
30
+ const granted = doc => {
31
+ if (allowedOrigins === null) return true;
32
+ const frame = doc.defaultView.frameElement;
33
+ if (frame) {
34
+ if (frame.hasAttribute('srcdoc') || !frame.getAttribute('src')) return false;
35
+ const source = new URL(frame.src);
36
+ if (!['http:', 'https:'].includes(source.protocol) || !allowedOrigins.includes(source.origin)) return false;
37
+ }
38
+ const url = new URL(doc.URL);
39
+ return ['http:', 'https:'].includes(url.protocol) && allowedOrigins.includes(url.origin);
40
+ };
16
41
  const identity = e => {
17
42
  if (!registry.ids.has(e)) registry.ids.set(e, registry.next++);
18
43
  return registry.ids.get(e);
@@ -34,9 +59,30 @@
34
59
  const u = new URL(a.href, location.href);
35
60
  return u.origin === location.origin ? u.pathname + u.search : u.href;
36
61
  };
37
- const push = (kind, text, extra = {}) => {
62
+ const controlOf = nodes => {
63
+ let found = null;
64
+ for (const node of nodes) {
65
+ if (!clean(node.nodeType === 3 ? node.textContent : (node.innerText ?? node.textContent ?? ''))) continue;
66
+ let host = node.nodeType === 1 ? node : node.parentElement;
67
+ while (host && !registry.hoverContexts?.has(host)) host = host.parentElement;
68
+ if (!host?.isConnected || !host.matches(':hover')) return null;
69
+ const binding = registry.hoverContexts.get(host);
70
+ const siblings = [...(host.parentNode?.children || [])];
71
+ const labelAttributes = [host.getAttribute('role'), host.getAttribute('aria-label'),
72
+ host.getAttribute('title'), host.querySelector('img[alt]')?.getAttribute('alt') || null];
73
+ if (host.innerText !== binding.text ||
74
+ labelAttributes.some((value, index) => value !== binding.labelAttributes[index])) return null;
75
+ // A reordered or replaced sibling set invalidates the observed ordinal before the quote is taken.
76
+ if (host.parentNode !== binding.parent || siblings.length !== binding.siblings.length ||
77
+ siblings.some((sibling, index) => sibling !== binding.siblings[index])) return null;
78
+ if (found && found !== host) return null;
79
+ found = host;
80
+ }
81
+ return found ? registry.hoverContexts.get(found).control : null;
82
+ };
83
+ const push = (kind, text, extra = {}, nodes = []) => {
38
84
  if (text)
39
- blocks.push({ kind, text, heading_path: [...path], frame_path: framePath, source_path: sourcePath, ...extra });
85
+ blocks.push({ kind, text, heading_path: [...path], frame_path: framePath, source_path: sourcePath, control_context: controlOf(nodes), ...extra });
40
86
  };
41
87
 
42
88
  // A cell can say what it says with an image or an icon alone: a flag marking the winner, a tick for "yes".
@@ -168,6 +214,8 @@
168
214
  let headers = rows.filter(({ row }) => row.parentElement.tagName === 'THEAD');
169
215
  if (!headers.length && rows[0].cells.every(c => c.tagName === 'TH')) headers = [rows[0]];
170
216
  const line = ({ cells }) => '| ' + cells.map(c => cellText(c).replace(/\|/g, '\\|')).join(' | ') + ' |';
217
+ // Headerless comparison rows need leading cells for identity and separate source spans for counting.
218
+ if (!headers.length) return rows.map((row, index) => (index ? [line(rows[0]), line(row)].join('\n') : line(row)));
171
219
  const prefix = headers.map(line);
172
220
  if (headers.length) prefix.push('| ' + headers[0].cells.map(() => '---').join(' | ') + ' |');
173
221
  const data = rows.filter(row => !headers.includes(row));
@@ -184,8 +232,8 @@
184
232
  elements[0].href &&
185
233
  clean(elements[0].innerText ?? '') === text;
186
234
  for (const e of elements) text = withRating(text, e);
187
- if (onlyLink) push('link', text, { href: hrefOf(elements[0]) });
188
- else push('paragraph', text);
235
+ if (onlyLink) push('link', text, { href: hrefOf(elements[0]) }, run);
236
+ else push('paragraph', text, {}, run);
189
237
  for (const e of elements) for (const field of fields(e)) push('paragraph', field);
190
238
  run.length = 0;
191
239
  }
@@ -197,13 +245,22 @@
197
245
  // A classless div repeated three times is usually page layout; a record is a styled unit or a semantic item.
198
246
  function repeated(el) {
199
247
  if (!el.classList.length && el.tagName !== 'ARTICLE' && el.tagName !== 'LI') return false;
200
- return (
201
- [...(el.parentNode?.children ?? [])].filter(
248
+ const siblings = [...(el.parentNode?.children ?? [])].filter(
202
249
  sibling =>
203
250
  sibling.tagName === el.tagName &&
204
251
  sibling.classList.length === el.classList.length &&
205
252
  [...el.classList].every(token => sibling.classList.contains(token)),
206
- ).length >= 3
253
+ );
254
+ if (siblings.length >= 3) return true;
255
+ // Linked cards can sit inside an unlabelled list wrapper, so distinct destinations establish the pair.
256
+ if (siblings.length === 2 && el.tagName === 'A' && siblings.every(sibling => sibling.hasAttribute('href'))) {
257
+ return new Set(siblings.map(sibling => sibling.href)).size === siblings.length;
258
+ }
259
+ // A styled pair can be a two-column layout, so require a heading outside both items to name their group.
260
+ const section = el.parentElement;
261
+ return (
262
+ siblings.length === 2 &&
263
+ [...(section?.querySelectorAll('h1,h2,h3,h4,h5,h6') ?? [])].some(heading => siblings.every(sibling => !sibling.contains(heading)))
207
264
  );
208
265
  }
209
266
 
@@ -237,7 +294,7 @@
237
294
  continue;
238
295
  }
239
296
  if (SKIP.has(node.tagName)) continue;
240
- if (!isBlock(node) && recordText(node) === null) {
297
+ if (!node.matches('nav,[role="navigation"]') && !isBlock(node) && recordText(node) === null) {
241
298
  run.push(node);
242
299
  continue;
243
300
  }
@@ -248,30 +305,38 @@
248
305
  }
249
306
 
250
307
  function block(el) {
308
+ if (el.matches('nav,[role="navigation"]')) {
309
+ // Navigation help headings describe their menu, not product facts in the following main content.
310
+ const surrounding = [...path];
311
+ walk(el);
312
+ path = surrounding;
313
+ return;
314
+ }
251
315
  const level = HEADINGS[el.tagName];
252
316
  if (level) {
253
317
  const text = textOf(el);
254
318
  while (path.length >= level) path.pop();
255
319
  if (text) {
256
- push('heading', text);
320
+ push('heading', text, {}, [el]);
257
321
  path.push(text);
258
322
  }
259
323
  return;
260
324
  }
261
325
  if (el.tagName === 'TABLE') {
262
- for (const text of renderTable(el)) push('table', text);
326
+ for (const text of renderTable(el)) push('table', text, {}, [el]);
263
327
  // A form laid out in a table: its cells render as text, which holds none of the controls' values.
264
328
  for (const field of fields(el)) push('paragraph', field);
265
329
  return;
266
330
  }
267
- if (el.tagName === 'PRE') return push('code', textOf(el));
331
+ if (el.tagName === 'PRE') return push('code', textOf(el), {}, [el]);
268
332
  const record = recordText(el);
269
333
  if ((el.tagName === 'LI' && leaf(el)) || record !== null) {
270
- const links = el.querySelectorAll('a[href]');
334
+ const links = el.matches('a[href]') ? [el] : el.querySelectorAll('a[href]');
271
335
  push(
272
336
  el.tagName === 'LI' ? 'list_item' : 'record',
273
337
  record ?? withRating(textOf(el), el),
274
338
  links.length === 1 ? { href: hrefOf(links[0]) } : {},
339
+ [el],
275
340
  );
276
341
  for (const field of fields(el)) push('paragraph', field);
277
342
  return;
@@ -296,7 +361,7 @@
296
361
  } catch {
297
362
  inner = null;
298
363
  }
299
- if (!inner?.body) {
364
+ if (!inner?.body || !granted(inner)) {
300
365
  inaccessible++;
301
366
  return;
302
367
  }
@@ -310,6 +375,45 @@
310
375
  framePath = frame;
311
376
  sourcePath = source;
312
377
  walk(root);
378
+ path = [...headings];
379
+ const visible = e => e.checkVisibility ? e.checkVisibility({ visibilityProperty: true }) : !hidden(e);
380
+ for (const e of [...root.querySelectorAll('input,select,textarea,button,[role="button"]')].filter(visible)) {
381
+ if (controlObservations >= 64) {
382
+ if (!controlsOmitted) push('observation', 'Additional control state observations omitted.');
383
+ controlsOmitted = true;
384
+ break;
385
+ }
386
+ // Placeholders suggest input; they do not label a value or prove an empty named field.
387
+ if (CONTROLS.has(e.tagName) && !e.labels?.length && !e.hasAttribute('aria-label') && !e.hasAttribute('aria-labelledby')) continue;
388
+ const name = nameOf(e) || clean(e.innerText ?? '');
389
+ if (!name || e.dataset?.fastbrowseSecret === '1') continue;
390
+ if (CONTROLS.has(e.tagName) && !UNFILLED.has(e.type) && !fieldValue(e)) {
391
+ push('observation', `${name.slice(0, 1024)}: [empty]`);
392
+ controlObservations++;
393
+ }
394
+ if (controlObservations < 64 && e.matches(':disabled,[aria-disabled="true"]')) {
395
+ push('observation', `${name.slice(0, 1024)}: disabled`);
396
+ controlObservations++;
397
+ }
398
+ }
399
+ // Images carry no innerText, so a text-only capture cannot distinguish their presence from absence.
400
+ for (const image of [...root.querySelectorAll('img')].filter(visible)) {
401
+ if (imageObservations++ >= 32) {
402
+ if (!imagesOmitted) push('observation', 'Additional visible image metadata omitted.');
403
+ imagesOmitted = true;
404
+ break;
405
+ }
406
+ // Loading changes without a page edit, so captures report presence rather than transient decode state.
407
+ const metadata = { alt: image.getAttribute('alt')?.slice(0, 1024) ?? null,
408
+ src: imageSource(image.getAttribute('src') ?? image.getAttribute('srcset')) };
409
+ push('observation', `Image element DOM metadata: ${JSON.stringify(metadata)}. Pixels are not described.`);
410
+ }
411
+ const h1 = [...root.querySelectorAll('h1,[role="heading"][aria-level="1"]')].filter(visible);
412
+ visibleH1 ||= h1.length > 0;
413
+ if (root === document.body) {
414
+ const doc = document.documentElement;
415
+ push('observation', `Observed viewport: Horizontal overflow: ${doc.scrollWidth > doc.clientWidth ? 'yes' : 'no'}`);
416
+ }
313
417
  for (const e of root.querySelectorAll('*')) {
314
418
  if (e.shadowRoot) scope(e.shadowRoot, frame, `${source}/shadow:${identity(e)}`);
315
419
  // A frame the walk never reached (hidden, or inside a record or table read as one block) is still read.
@@ -319,5 +423,6 @@
319
423
  }
320
424
 
321
425
  scope(document.body, null, '');
322
- return { url: location.href, title: document.title, blocks, inaccessible_frames: inaccessible };
323
- })()
426
+ if (!visibleH1) push('observation', 'Observed accessible DOM: Visible h1 headings: 0');
427
+ return { url: location.href, title: document.title, blocks, inaccessible_frames: inaccessible, visible_h1: visibleH1 };
428
+ })
@@ -3,7 +3,18 @@
3
3
  // Adapted to fastbrowse's Control shape (role + operation set, not per-kind actions) and to traverse
4
4
  // open shadow roots. Runs once per frame session (main frame or an OOPIF); the Python side merges frames.
5
5
  // biome-ignore format: Python appends mode arguments to this parenthesized expression.
6
- (mode => {
6
+ ((mode, allowedOrigins = null) => {
7
+ const granted = doc => {
8
+ if (allowedOrigins === null) return true;
9
+ const frame = doc.defaultView.frameElement;
10
+ if (frame) {
11
+ if (frame.hasAttribute('srcdoc') || !frame.getAttribute('src')) return false;
12
+ const source = new URL(frame.src);
13
+ if (!['http:', 'https:'].includes(source.protocol) || !allowedOrigins.includes(source.origin)) return false;
14
+ }
15
+ const url = new URL(doc.URL);
16
+ return ['http:', 'https:'].includes(url.protocol) && allowedOrigins.includes(url.origin);
17
+ };
7
18
  // Form context is repeated for each submit-capable input in safety prompts.
8
19
  const FORM_TEXT_CHARS = 2000;
9
20
  // Freshness guards bound comparison work on large cards; this text is never sent to a model.
@@ -117,7 +128,7 @@
117
128
  } catch {
118
129
  inner = null;
119
130
  }
120
- if (inner?.body) include(inner);
131
+ if (inner?.body && granted(inner)) include(inner);
121
132
  }
122
133
  }
123
134
  };
@@ -165,10 +176,22 @@
165
176
  registry.visible = visible;
166
177
  // Styled checkboxes and radios often hide the native input. Its visible label is the click
167
178
  // target, but the input still owns the checked/disabled state and must participate in freshness.
168
- const sourceOf = e => (e.tagName === 'LABEL' && ['checkbox', 'radio'].includes(e.control?.type) ? e.control : e);
179
+ const sourceOf = e => (e.tagName === 'LABEL' && ['checkbox', 'radio', 'file'].includes(e.control?.type) ? e.control : e);
169
180
 
170
181
  // Their text is code, not a name: Amazon nests a <style> inside a result card's link.
171
182
  const CODE = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE']);
183
+ // Icon-only buttons hide their glyph from accessibility text, but the icon library still names it.
184
+ const iconLabel = e => {
185
+ const glyphs = new Set();
186
+ for (const icon of e.querySelectorAll('svg')) {
187
+ if (!showing(icon)) continue;
188
+ for (const token of icon.classList) {
189
+ const glyph = /^(?:icon-tabler|tabler-icon|lucide)-([a-z][a-z0-9-]{0,47})$/.exec(token);
190
+ if (glyph && !/^(?:icon|xs|sm|lg|xl|\d+x|spin|pulse)$/.test(glyph[1])) glyphs.add(glyph[1]);
191
+ }
192
+ }
193
+ return glyphs.size === 1 ? `${[...glyphs][0].replace(/-/g, ' ')} icon` : '';
194
+ };
172
195
  const labelOf = (e, seen = new Set()) => {
173
196
  if (!e || seen.has(e)) return '';
174
197
  seen.add(e);
@@ -233,7 +256,7 @@
233
256
  const DATE_TYPES = ['date', 'datetime-local', 'month', 'week', 'time'];
234
257
 
235
258
  const roleOf = e => {
236
- if (sourceOf(e) !== e) return sourceOf(e).type;
259
+ if (sourceOf(e) !== e) return sourceOf(e).type === 'file' ? 'textbox' : sourceOf(e).type;
237
260
  const explicit = e.getAttribute('role');
238
261
  if (ARIA_ROLES.includes(explicit)) return explicit;
239
262
  if (e.tagName === 'BUTTON' || e.tagName === 'SUMMARY') return 'button';
@@ -259,6 +282,7 @@
259
282
 
260
283
  const submitSemantics = e => {
261
284
  const form = e.form;
285
+ const explicit = ['BUTTON', 'INPUT'].includes(e.tagName) && ['submit', 'image'].includes(e.type);
262
286
  const implicit = [
263
287
  'text',
264
288
  'search',
@@ -273,8 +297,8 @@
273
297
  'datetime-local',
274
298
  'number',
275
299
  ];
276
- if (!form || e.tagName !== 'INPUT' || !implicit.includes(e.type)) return null;
277
- const submit = [...form.getRootNode().querySelectorAll('button,input')].find(
300
+ if (!form || (!explicit && (e.tagName !== 'INPUT' || !implicit.includes(e.type)))) return null;
301
+ const submit = explicit ? e : [...form.getRootNode().querySelectorAll('button,input')].find(
278
302
  c => c.form === form && (c.type === 'submit' || c.type === 'image'),
279
303
  );
280
304
  if (submit?.matches(':disabled')) return null;
@@ -311,7 +335,7 @@
311
335
  } catch {
312
336
  inner = null;
313
337
  }
314
- if (inner && inner.body) yield* walk(inner);
338
+ if (inner && inner.body && granted(inner)) yield* walk(inner);
315
339
  else inaccessible++;
316
340
  }
317
341
  }
@@ -342,6 +366,8 @@
342
366
  e.ownerDocument.location.origin,
343
367
  e.ownerDocument.defaultView.performance.timeOrigin,
344
368
  submitSemantics(e),
369
+ source.getAttribute('name'),
370
+ source.getAttribute('autocomplete'),
345
371
  identity(source),
346
372
  ];
347
373
  };
@@ -376,16 +402,26 @@
376
402
  rname = roleOf(e);
377
403
  if (!rname || r.width <= 0 || r.height <= 0 || x < 0 || x >= innerWidth) continue;
378
404
  if (rname === 'gridcell' && e.querySelector('button,[role="button"]')) continue;
405
+ // A button-styled link offers the same label twice, but its inner button owns every useful click point.
406
+ if (rname === 'link') {
407
+ const nested = e.querySelectorAll(SELECTOR);
408
+ const button = nested.length === 1 ? nested[0] : null;
409
+ if (button?.tagName === 'BUTTON' && visible(button) && !button.matches(':disabled') &&
410
+ !button.closest('[aria-disabled="true"],[inert]') && labelOf(button) === labelOf(e)) continue;
411
+ }
412
+
379
413
  const id = identity(e);
380
414
  registry.controls.add(e);
381
415
  const base = {
382
416
  id,
383
417
  role: rname,
384
- label: labelOf(e) || rname,
418
+ label: labelOf(e) || (rname === 'button' ? iconLabel(e) : '') || rname,
385
419
  offscreen: y < 0 || y >= innerHeight,
386
420
  distance: y < 0 || y >= innerHeight ? 1 + Math.abs(y - innerHeight / 2) : 0,
387
421
  sensitive: secret(source),
388
422
  input_type: source.type || null,
423
+ input_name: source.getAttribute('name') || null,
424
+ autocomplete: source.getAttribute('autocomplete') || null,
389
425
  frame_origin: e.ownerDocument.location.origin,
390
426
  frame_path: framePath(e.ownerDocument),
391
427
  form_id: fieldScope(source),
@@ -419,7 +455,7 @@
419
455
  base.operations = ['select'];
420
456
  base.options = [...e.options].filter(o => !o.disabled && !o.closest('optgroup[disabled]')).map(o => o.label);
421
457
  base.value = [...e.selectedOptions].map(o => o.label).join(', ');
422
- } else if (e.type === 'file') {
458
+ } else if (source.type === 'file') {
423
459
  base.operations = ['upload'];
424
460
  base.value = null;
425
461
  } else {
@@ -496,6 +532,9 @@
496
532
  for (let e = element.parentElement; e && e !== e.ownerDocument.body; e = e.parentElement) {
497
533
  if (twins.some(twin => twin !== element && e.contains(twin))) break;
498
534
  scope = e;
535
+ // A lone open reply has no form twin nearby; crossing its record picks a different commenter's name.
536
+ if (e.matches('article,li,[role="listitem"]') ||
537
+ (e.matches('tr,[role="row"]') && e.querySelector('input,textarea,select,[role="textbox"]'))) break;
499
538
  }
500
539
  return scope ? excerpt(nameOf(scope, element, label, nearest), CONTROL_CONTEXT_CHARS) : '';
501
540
  };
@@ -503,7 +542,7 @@
503
542
  // operation. An element whose hover rule would reveal something now hidden is offered as a hover target.
504
543
  const docs = [];
505
544
  const addDoc = doc => {
506
- if (!doc?.body || docs.includes(doc)) return;
545
+ if (!doc?.body || !granted(doc) || docs.includes(doc)) return;
507
546
  docs.push(doc);
508
547
  for (const frame of doc.querySelectorAll('iframe,frame')) {
509
548
  try {
@@ -581,12 +620,14 @@
581
620
  } catch {
582
621
  continue;
583
622
  }
584
- if (!hidden) continue;
623
+ // Removing the active hover target renumbers its identical siblings and sends the next choice
624
+ // to a different element. Keep it offered while the pointer still reveals its content.
625
+ if (!hidden && !e.matches(':hover')) continue;
585
626
  const image = e.querySelector('img[alt]');
586
627
  const label =
587
- firstLine(e.innerText) ||
588
628
  e.getAttribute('aria-label') ||
589
629
  image?.getAttribute('alt') ||
630
+ firstLine(e.innerText) ||
590
631
  e.getAttribute('title') ||
591
632
  e.tagName.toLowerCase();
592
633
  const role =
@@ -664,6 +705,24 @@
664
705
  }
665
706
  }
666
707
 
708
+ // A revealed caption needs its actual container association, not merely the most recent action.
709
+ registry.hoverContexts = new WeakMap();
710
+ for (const c of controls) {
711
+ if (!c.operations.includes('hover')) continue;
712
+ const element = registry.nodes.get(c.id);
713
+ registry.hoverContexts.set(element, {
714
+ control: {
715
+ role: c.role, label: c.label,
716
+ context: /^\d+ of \d+$/.test(c.context || '') ? c.context : null,
717
+ },
718
+ labelAttributes: [element.getAttribute('role'), element.getAttribute('aria-label'),
719
+ element.getAttribute('title'), element.querySelector('img[alt]')?.getAttribute('alt') || null],
720
+ text: element.innerText,
721
+ parent: element.parentNode,
722
+ siblings: [...(element.parentNode?.children || [])],
723
+ });
724
+ }
725
+
667
726
  // A date field's own label ("Start Date") rarely says which picker it belongs to when a page offers several
668
727
  // ("Date Picker 3"): unlike a colliding label, a uniquely named one never reaches the loop above, so the
669
728
  // nearest ancestor that names a section is attached here on its own, without the twin-avoidance `contextOf`
@@ -777,7 +836,7 @@
777
836
  } catch {
778
837
  inner = null;
779
838
  }
780
- if (!inner) continue;
839
+ if (!inner || !granted(inner)) continue;
781
840
  const f = frame.getBoundingClientRect();
782
841
  readText(inner, dx + f.left + frame.clientLeft, dy + f.top + frame.clientTop);
783
842
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fastbrowse
3
- Version: 0.5.19
3
+ Version: 0.5.20
4
4
  Summary: Browser automation with cited answers, structured output and code-controlled credentials and authorization.
5
5
  Project-URL: Homepage, https://github.com/agent-labs-dev/fastbrowse
6
6
  Project-URL: Repository, https://github.com/agent-labs-dev/fastbrowse
@@ -30,15 +30,15 @@ Description-Content-Type: text/markdown
30
30
  <img src="assets/wordmark.svg" alt="fastbrowse" width="360">
31
31
  </picture>
32
32
 
33
- **Browser automation with results you can verify.**
33
+ **Give your agent a browser.**
34
34
 
35
- Complete workflows, return cited answers or structured data, and check the result before your application acts on it.
36
- Jev chooses actions from the page; code controls credentials, authorization and spending.
35
+ Run browser tasks locally or in a cloud browser. Jev chooses from indexed page controls; fastbrowse returns
36
+ cited answers or schema-validated data. Code controls credentials, authorization and spending.
37
37
 
38
38
  [![pypi](https://img.shields.io/pypi/v/fastbrowse?style=flat-square&color=6366F1)](https://pypi.org/project/fastbrowse/)
39
39
  ![python](https://img.shields.io/badge/python-3.13%20%7C%203.14-475569?style=flat-square)
40
40
  ![license](https://img.shields.io/badge/license-MIT-475569?style=flat-square)
41
- ![status](https://img.shields.io/badge/status-pre--alpha-6366F1?style=flat-square)
41
+ ![status](https://img.shields.io/badge/status-beta-6366F1?style=flat-square)
42
42
 
43
43
  </div>
44
44
 
@@ -47,53 +47,23 @@ Jev chooses actions from the page; code controls credentials, authorization and
47
47
  ## Why
48
48
 
49
49
  fastbrowse indexes the page into candidates and has [Jev](https://typesafe.ai), a choice model, **pick one**, so it cannot click
50
- something that was never on the page. Every claim in an answer cites a verbatim quote from the page.
50
+ something that was never on the page. Factual claims in a complete answer cite verbatim quotes from the page.
51
51
 
52
52
  ![fastbrowse signing in to a shop, adding two products, filling the shipping form and placing the order](docs/assets/demo.gif)
53
53
 
54
- 13 steps in 20.9s on local Chrome, shown 1.4x faster with pauses cut.
54
+ Recorded on local Chrome: 13 steps, 20.9s and $0.0034 in model calls. Playback is 1.4x faster with pauses cut.
55
55
 
56
56
  ### Against Browser Use agents
57
57
 
58
- The Ultrafast comparison covers six navigation tasks against Browser Use Ultrafast (`jev-ultrafast`).
59
- Earlier hosted-agent comparisons and their grading limitations remain in the [eval history](docs/evals.md#results).
60
- Scored-run medians exclude earlier outage attempts. The [full audit](docs/validation/2026-09-29-final-comparison.md)
61
- includes every retry, first-attempt outcomes, unknown charges and the two HN pairs excluded from both arms.
58
+ The [benchmark page](https://fastbrowse.ai/benchmarks) reports task success, speed and cost,
59
+ with methods and run summaries.
62
60
 
63
- <!-- evals:headline -->
64
- Measured on 2026-09-29 with the build released as 0.5.14: 6 tasks, 120 selected results across all arms, on cloud browsers.
65
-
66
- Suite `core` `c0785e8b`: fastbrowse against Browser Use Ultrafast, on the same 6 tasks.
67
-
68
- | | runs passed | cost per scored run | median time |
69
- |:--|:--|:--|:--|
70
- | fastbrowse | 58/58 | $0.0026 (median), $0.0031 mean | 8.3s |
71
- | Browser Use Ultrafast | 38/58 | $0.0014 (median), $0.0129 mean | 12.9s |
72
-
73
- Each arm has 60 selected results, excluding earlier retries. Unavailable results: 2 for fastbrowse. Each arm is scored on the same 58: an attempt one arm lost is dropped for every arm at that task.
74
-
75
- ### Controlled mock-site comparison
76
-
77
- Release 0.5.9, 2026-09-28. Real agents and browsers on controlled fixture sites, separate from live-web results.
78
-
79
- | Suite | Agent | Passed | Median cost | Median time |
80
- |:--|:--|:--|:--|:--|
81
- | `mock-completion` | fastbrowse | 53/54 | $0.0065 | 22.8s |
82
- | `mock-completion` | Browser Use agent | 54/54 | $0.2543 | 46.0s |
83
- | `mock-safety` | fastbrowse | 6/6 | $0.0103 | 30.4s |
84
- | `mock-safety` | Browser Use agent | 6/6 | $0.5763 | 85.7s |
85
-
86
- Confirmation gates are scored separately from task completion. Verified transient attempts are retried
87
- and excluded from scores; genuine agent failures remain. Full protocol and attempt records are in
88
- [docs/evals.md](docs/evals.md#stateful-mock-comparison).
89
- <!-- /evals:headline -->
90
-
91
- Compare rows only at matching task versions. See [eval results and workflow](docs/evals.md).
61
+ Rows are comparable only at matching task versions. See [eval results and workflow](docs/evals.md).
92
62
 
93
63
  ### Why fastbrowse, against each kind of agent
94
64
 
95
65
  - **LLM agents that generate actions** (the hosted Browser Use agent and similar): Jev picks each action from the controls
96
- that are on the page, so there is no invented selector to retry. Every claim in the answer links to the page text it came from.
66
+ that are on the page, so there is no invented selector to retry. Factual claims in a complete answer link to the page text they came from.
97
67
  - **Choice-model navigators** ([Browser Use Ultrafast](https://github.com/browser-use/jev-ultrafast), the
98
68
  `jev-ultrafast` package and eval arm): both choose actions with Jev. fastbrowse adds cited answers, schema-validated
99
69
  data, scoped credentials and an authorization gate. Navigation tasks compare the page each run ended on.
@@ -113,14 +83,15 @@ A Node program needs neither uv nor Python: see [Use it from JavaScript](#use-it
113
83
 
114
84
  ```sh
115
85
  export OPENROUTER_API_KEY=... # for Jev and the LLM that plans and reads
116
- # export AI_GATEWAY_API_KEY=... # optional Jev backup through the Vercel AI Gateway
86
+ # export AI_GATEWAY_API_KEY=... # Vercel AI Gateway: Jev backup, and the LLM when OPENROUTER_API_KEY is unset
117
87
  export BROWSER_USE_API_KEY=... # the cloud browser; or pass --local to use Chrome
118
88
  uvx fastbrowse "What is the title of the top story right now?" --start https://news.ycombinator.com/
119
89
  ```
120
90
 
121
91
  Jev uses direct TypeSafe when `TYPESAFE_API_KEY` is supplied; otherwise OpenRouter is primary.
122
92
  Vercel AI Gateway is supported as a backup or an explicit primary. `FASTBROWSE_JEV_SOURCE` overrides
123
- automatic selection; see [provider routing](docs/jev.md#provider-failover). The LLM uses OpenRouter.
93
+ automatic selection; see [provider routing](docs/jev.md#provider-failover). The LLM uses OpenRouter when
94
+ `OPENROUTER_API_KEY` is set, otherwise the Vercel AI Gateway's OpenAI-compatible chat completions.
124
95
 
125
96
  `uvx` runs the published package in an isolated cached environment. `uv tool install fastbrowse` keeps it on your
126
97
  PATH, and `uv add fastbrowse` puts it in a project. Service keys can live in a `.env` file in the working directory;
@@ -154,10 +125,11 @@ the quotes behind the answer, and cost by component.
154
125
  | `--authorize` | allow submit, pay, delete and send; without it the run stops at `needs_confirmation` first |
155
126
  | `--secret NAME=ENV_VAR[@ORIGIN]` | let the agent type `$ENV_VAR` on the declared origin, or the `--start` origin if omitted; models only see `NAME`. An explicit origin needs no `--start` |
156
127
  | `--bitwarden ITEM` | match the vault login's saved URIs against `--start`, then allow its `username`, `password` and, when the item holds an authenticator key, `one_time_code` only on that start origin |
157
- | `--max-steps N`, `--max-dollars N` | bound steps and model spend; defaults are 60 steps and no dollar cap. Cloud browser charges are added when it stops |
128
+ | `--max-steps N`, `--max-dollars N` | optionally bound steps and model spend; defaults are unlimited. Cloud browser charges are added when it stops |
158
129
  | `--downloads DIR` | keep downloaded files |
159
130
  | `--json` | full result instead of the answer |
160
131
  | `--record FILE` | save an MP4 of the tab, each step captioned, ending on the answer, time and cost (needs `ffmpeg`; the captions need its libass), e.g. `recordings/demo.mp4`, which git ignores; `demo.plain.mp4` beside it has no captions. It shows what the pages showed, so watch it before sharing |
132
+ | `--cursor` | draw the agent's cursor over a visible Chrome (`--headed`, or one you attach to) with [Cua Driver](https://github.com/trycua/cua), so you can watch where it acts. Off by default. It needs `cua-driver` on `PATH` and an X11 display on Linux, and does nothing without them |
161
133
 
162
134
  ```sh
163
135
  export SAUCE_PASSWORD=secret_sauce
@@ -223,7 +195,7 @@ Close the app, or restart it without the flag, when you are done.
223
195
  ### Models
224
196
 
225
197
  The LLM defaults to `google/gemini-3.8-flash` at low reasoning effort, with
226
- `google/gemini-3.5-flash-lite` for planning, proposing a direct address and typing field text.
198
+ `google/gemini-3.5-flash-lite` for proposing a direct address and typing field text.
227
199
  Override with `FASTBROWSE_LLM_MODEL` (every purpose), `FASTBROWSE_LLM_MODEL_<PURPOSE>` (`PLAN`,
228
200
  `READ`, `FIELD_TEXT`, `SHORTCUT`, `RECOVER`, `COMPOSE`, `VERIFY`) and `FASTBROWSE_LLM_REASONING`
229
201
  (`low`, `medium`, `high`).
@@ -242,14 +214,26 @@ Programs that only check zero versus nonzero continue to work.
242
214
  | `blocked` | 6 | a bot check (a CAPTCHA) that did not clear; not a sign-in, so no secret passes it |
243
215
  | `needs_input` | 5 | a required value or file is missing, or an upload exceeds the configured size limit |
244
216
  | `stuck` | 9 | recovery ran out without reaching a page state the run had not seen |
245
- | `budget_exceeded` | 7 | a step, call, time or dollar limit was reached |
217
+ | `budget_exceeded` | 7 | a resource limit was reached, or a missing cost prevents enforcing the dollar cap |
246
218
  | `observation_limit` | 11 | the page or required evidence cannot fit the configured prompt budget |
247
219
  | `unavailable` | 8 | a model or browser provider stayed unavailable through every retry; the same run later may pass |
248
220
  | `error` | 1 | a model or browser failure |
249
221
 
250
222
  A `budget_exceeded` JSON result includes `budget.resource` (`steps`, `seconds`, `dollars`, `jev_calls` or
251
223
  `llm_calls`) and `budget.limit`. Other results have `budget: null`. The embedding and MCP APIs carry the same
252
- optional object, so callers can identify the exhausted limit without parsing an error message.
224
+ optional object, so callers can identify the affected limit without parsing an error message.
225
+
226
+ A bot check can block a cloud browser before any task steps execute. Retry with an attached local browser
227
+ or choose another source for the research. Credentials do not resolve a CAPTCHA.
228
+
229
+ If a model completion omits its cost, fastbrowse looks up the matching OpenRouter or Vercel generation
230
+ receipt. If that receipt is still unavailable, a run with a dollar cap stops with `budget_exceeded`;
231
+ this does not mean the known charges reached the cap. Supplied `inputs` can provide exact field text
232
+ without a field-writing generation.
233
+
234
+ A browser command that remains unanswered for 120 seconds returns `unavailable`, even if the browser
235
+ still answers health checks. Step and dollar limits do not bound total elapsed time. Set `Limits(max_seconds=...)` for a run deadline.
236
+ When investigating a stalled run, enable trace logging and retain the last event alongside the result.
253
237
 
254
238
  ## How it works
255
239
 
@@ -462,6 +446,18 @@ There is no binary for Alpine or another musl system, and none for a platform ou
462
446
  that allows programs only by the team that signed them refuses. The Windows binary is not signed. Bun and Deno are
463
447
  untested.
464
448
 
449
+ ## Use fastbrowse with your agent
450
+
451
+ Install the [fastbrowse skill](docs/skill.md) to delegate website tasks from an agent that supports Agent Skills. It uses the MCP
452
+ tool when connected, or the CLI, and preserves the task's citations, status, authorization, and budget.
453
+ The guide covers agent selection, configuration, and example prompts for Codex and Claude Code.
454
+ Agents without skill support can use the CLI or MCP server directly.
455
+
456
+ ## Run fastbrowse through Nebula
457
+
458
+ [Nebula](https://www.nebula.gg/) embeds fastbrowse in its agent harness, with browser steps, results and
459
+ permitted vault logins. Use Bitwarden or 1Password through Nebula with credentials scoped to authorized websites.
460
+
465
461
  ## Use it from an MCP client
466
462
 
467
463
  `fastbrowse-mcp` serves one `browse` tool over [MCP](https://modelcontextprotocol.io), so Claude Code, Claude
@@ -493,12 +489,12 @@ The server's flags decide what a calling model may do; a call can ask for less,
493
489
 
494
490
  | Flag | Effect |
495
491
  |:--|:--|
496
- | `--local`, `--headed`, `--profile DIR`, `--downloads DIR` | as for the CLI, fixed for every call |
492
+ | `--local`, `--headed`, `--profile DIR`, `--downloads DIR`, `--cursor` | as for the CLI, fixed for every call |
497
493
  | `--cloud-profile ID` | every call runs signed in as that cloud profile; a calling model cannot choose it |
498
494
  | `--allow-authorize` | let a call pass `authorize` to go through irreversible actions; without it they always stop at `needs_confirmation` |
499
495
  | `--secret NAME=ENV_VAR@ORIGIN` | typed when a call's start page is on `ORIGIN` (`https://*.site.com` covers its hosts); the model sees `NAME` only |
500
496
  | `--bitwarden ITEM` | a vault login a call may name in `bitwarden` |
501
- | `--max-steps N`, `--max-dollars N`, `--max-seconds N` | ceilings per call (defaults 60, $1.00, 600s) |
497
+ | `--max-steps N`, `--max-dollars N`, `--max-seconds N` | optional ceilings per call; defaults are unlimited |
502
498
  | `--max-concurrent N` | runs at once, default 1; more calls wait their turn |
503
499
  | `--transport http`, `--host`, `--port` | streamable HTTP at `/mcp` instead of stdio |
504
500
 
@@ -521,9 +517,9 @@ seconds to minutes, so raise the client's tool timeout if it has one (`MCP_TOOL_
521
517
 
522
518
  ## Evals and development
523
519
 
524
- The [eval workflow](docs/evals.md#workflow) has the run, publish and regeneration commands. Final page checks
520
+ [Eval commands](docs/evals.md) cover running suites, publishing results and regenerating evidence. Final page checks
525
521
  require evidence read by the harness, and a pass also requires the task's expected ending. Published rows carry
526
- build and task versions; the [site feed](docs/results/summary.json) is generated from them.
522
+ build and task versions; the [compact results feed](docs/results/summary.json) is generated from them.
527
523
 
528
524
  See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and checks, [docs/design.md](docs/design.md) for the browser
529
525
  layer, and [docs/jev.md](docs/jev.md) for the Jev assumptions checked against Typesafe's documentation.
@@ -0,0 +1,14 @@
1
+ ../../../bin/fastbrowse,sha256=k_VHUJGUyyMLHeLCiSHLkBTLNP1m86q6e4YkyAHdlZM,337
2
+ ../../../bin/fastbrowse-mcp,sha256=1CAFjtHrbGFIoAzljudiwUZ-Qrz8WFq5CLTPaDEvGRE,344
3
+ _editable_impl_fastbrowse.pth,sha256=Ft6bxdkTegROr5p9hZSxsnUSfEHrZYCfXWKjtpA4_DU,44
4
+ fastbrowse-0.5.20.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
5
+ fastbrowse-0.5.20.dist-info/METADATA,sha256=TRP1vQPXRmVjUohcVe1cPzAiSIAyyu5deui0sw5RpmA,31373
6
+ fastbrowse-0.5.20.dist-info/RECORD,,
7
+ fastbrowse-0.5.20.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ fastbrowse-0.5.20.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ fastbrowse-0.5.20.dist-info/direct_url.json,sha256=cZGZY9ovdib3X-RhjHdlyO_0_EMUe0tW6AXsSyMbNE4,86
10
+ fastbrowse-0.5.20.dist-info/entry_points.txt,sha256=SlQAK1_4_oywTAMckx8BYuCQJva0F-QuShQPcxtUm-g,95
11
+ fastbrowse-0.5.20.dist-info/licenses/LICENSE,sha256=gcUazG_j5a2blKPmfKqRKE98KOqX2kPbWY-EOtQZiNU,1067
12
+ fastbrowse-0.5.20.dist-info/licenses/NOTICE,sha256=qEO0Az_Tk98vRVL0xjnGYgMvexlMCA92lWl4JBwYRzg,1854
13
+ fastbrowse-0.5.20.dist-info/uv_build.json,sha256=RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o,2
14
+ fastbrowse-0.5.20.dist-info/uv_cache.json,sha256=GWjVYY9Vp6-ng3PbDCtLXRAAV5WI5xeIq-BkZ9dZnZ0,194
@@ -0,0 +1 @@
1
+ {"timestamp":{"secs_since_epoch":1791608646,"nanos_since_epoch":209709708},"commit":null,"tags":null,"env":{},"directories":{"src":{"secs_since_epoch":1791608646,"nanos_since_epoch":211240749}}}
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastbrowse/darwin-arm64",
3
- "version": "0.5.19",
3
+ "version": "0.5.20",
4
4
  "description": "The fastbrowse binary for macOS on arm64.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,14 +0,0 @@
1
- ../../../bin/fastbrowse,sha256=k_VHUJGUyyMLHeLCiSHLkBTLNP1m86q6e4YkyAHdlZM,337
2
- ../../../bin/fastbrowse-mcp,sha256=1CAFjtHrbGFIoAzljudiwUZ-Qrz8WFq5CLTPaDEvGRE,344
3
- _editable_impl_fastbrowse.pth,sha256=Ft6bxdkTegROr5p9hZSxsnUSfEHrZYCfXWKjtpA4_DU,44
4
- fastbrowse-0.5.19.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
5
- fastbrowse-0.5.19.dist-info/METADATA,sha256=l_hggNhjxHhCyunHbMTPUkdpyJ3tl79Y1AHPhtN7EKo,31033
6
- fastbrowse-0.5.19.dist-info/RECORD,,
7
- fastbrowse-0.5.19.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- fastbrowse-0.5.19.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
- fastbrowse-0.5.19.dist-info/direct_url.json,sha256=cZGZY9ovdib3X-RhjHdlyO_0_EMUe0tW6AXsSyMbNE4,86
10
- fastbrowse-0.5.19.dist-info/entry_points.txt,sha256=SlQAK1_4_oywTAMckx8BYuCQJva0F-QuShQPcxtUm-g,95
11
- fastbrowse-0.5.19.dist-info/licenses/LICENSE,sha256=gcUazG_j5a2blKPmfKqRKE98KOqX2kPbWY-EOtQZiNU,1067
12
- fastbrowse-0.5.19.dist-info/licenses/NOTICE,sha256=qEO0Az_Tk98vRVL0xjnGYgMvexlMCA92lWl4JBwYRzg,1854
13
- fastbrowse-0.5.19.dist-info/uv_build.json,sha256=RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o,2
14
- fastbrowse-0.5.19.dist-info/uv_cache.json,sha256=56hZcPp9TKFHS_cHNMZvd-M8ZP3zllN_UK0f4-ceyEc,194
@@ -1 +0,0 @@
1
- {"timestamp":{"secs_since_epoch":1791321494,"nanos_since_epoch":878437009},"commit":null,"tags":null,"env":{},"directories":{"src":{"secs_since_epoch":1791321494,"nanos_since_epoch":879909245}}}