@fastbrowse/darwin-x64 0.5.19 → 0.5.23

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 (27) hide show
  1. package/fastbrowse/_internal/base_library.zip +0 -0
  2. package/fastbrowse/_internal/fastbrowse/browser/capture.js +193 -17
  3. package/fastbrowse/_internal/fastbrowse/browser/snapshot.js +150 -13
  4. package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/METADATA +117 -60
  5. package/fastbrowse/_internal/fastbrowse-0.5.23.dist-info/RECORD +14 -0
  6. package/fastbrowse/_internal/fastbrowse-0.5.23.dist-info/uv_cache.json +1 -0
  7. package/fastbrowse/_internal/websockets/speedups.cpython-313-darwin.so +0 -0
  8. package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/METADATA +2 -2
  9. package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/RECORD +22 -22
  10. package/fastbrowse/fastbrowse +0 -0
  11. package/package.json +1 -1
  12. package/fastbrowse/_internal/fastbrowse-0.5.19.dist-info/RECORD +0 -14
  13. package/fastbrowse/_internal/fastbrowse-0.5.19.dist-info/uv_cache.json +0 -1
  14. /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/INSTALLER +0 -0
  15. /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/REQUESTED +0 -0
  16. /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/WHEEL +0 -0
  17. /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/direct_url.json +0 -0
  18. /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/entry_points.txt +0 -0
  19. /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/licenses/LICENSE +0 -0
  20. /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/licenses/NOTICE +0 -0
  21. /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/uv_build.json +0 -0
  22. /package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/INSTALLER +0 -0
  23. /package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/REQUESTED +0 -0
  24. /package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/WHEEL +0 -0
  25. /package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/entry_points.txt +0 -0
  26. /package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/licenses/LICENSE +0 -0
  27. /package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/top_level.txt +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,34 @@
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 linkObservations = 0;
20
+ let linksOmitted = false;
21
+ let controlsOmitted = false;
22
+ const imageSource = source => {
23
+ if (!source) return source;
24
+ if (source.startsWith('data:')) {
25
+ const comma = source.indexOf(',');
26
+ return `${source.slice(0, comma < 0 ? 128 : Math.min(comma, 128))},[payload omitted]`;
27
+ }
28
+ if (source.startsWith('blob:')) return 'blob:[identifier omitted]';
29
+ return source.length > 1024 ? `${source.slice(0, 1024)}[truncated]` : source;
30
+ };
15
31
  const registry = (window.__fastbrowse ||= { ids: new WeakMap(), nodes: new Map(), next: 1 });
32
+ const granted = doc => {
33
+ if (allowedOrigins === null) return true;
34
+ const frame = doc.defaultView.frameElement;
35
+ if (frame) {
36
+ if (frame.hasAttribute('srcdoc') || !frame.getAttribute('src')) return false;
37
+ const source = new URL(frame.src);
38
+ if (!['http:', 'https:'].includes(source.protocol) || !allowedOrigins.includes(source.origin)) return false;
39
+ }
40
+ const url = new URL(doc.URL);
41
+ return ['http:', 'https:'].includes(url.protocol) && allowedOrigins.includes(url.origin);
42
+ };
16
43
  const identity = e => {
17
44
  if (!registry.ids.has(e)) registry.ids.set(e, registry.next++);
18
45
  return registry.ids.get(e);
@@ -34,9 +61,67 @@
34
61
  const u = new URL(a.href, location.href);
35
62
  return u.origin === location.origin ? u.pathname + u.search : u.href;
36
63
  };
37
- const push = (kind, text, extra = {}) => {
64
+ const controlOf = nodes => {
65
+ let found = null;
66
+ for (const node of nodes) {
67
+ if (!clean(node.nodeType === 3 ? node.textContent : (node.innerText ?? node.textContent ?? ''))) continue;
68
+ let host = node.nodeType === 1 ? node : node.parentElement;
69
+ while (host && !registry.hoverContexts?.has(host)) host = host.parentElement;
70
+ if (!host?.isConnected || !host.matches(':hover')) return null;
71
+ const binding = registry.hoverContexts.get(host);
72
+ const siblings = [...(host.parentNode?.children || [])];
73
+ const labelAttributes = [host.getAttribute('role'), host.getAttribute('aria-label'),
74
+ host.getAttribute('title'), host.querySelector('img[alt]')?.getAttribute('alt') || null];
75
+ if (host.innerText !== binding.text ||
76
+ labelAttributes.some((value, index) => value !== binding.labelAttributes[index])) return null;
77
+ // A reordered or replaced sibling set invalidates the observed ordinal before the quote is taken.
78
+ if (host.parentNode !== binding.parent || siblings.length !== binding.siblings.length ||
79
+ siblings.some((sibling, index) => sibling !== binding.siblings[index])) return null;
80
+ if (found && found !== host) return null;
81
+ found = host;
82
+ }
83
+ return found ? registry.hoverContexts.get(found).control : null;
84
+ };
85
+ const listIds = new WeakMap();
86
+ const listFrameless = new WeakMap();
87
+ let nextListId = 0;
88
+ const listContext = nodes => {
89
+ const lists = new Set();
90
+ for (const node of nodes) {
91
+ let element = node.nodeType === 1 ? node : node.parentElement;
92
+ let list = null;
93
+ for (let parent = element; parent; parent = parent.parentElement) {
94
+ if (parent.matches('nav,[role="navigation"]')) return {};
95
+ if (parent.matches('ul,ol,[role="list"]')) list = parent;
96
+ }
97
+ if (!list) {
98
+ const nested = [...(element?.querySelectorAll('ul,ol,[role="list"]') ?? [])];
99
+ const outer = nested.filter(candidate => !nested.some(other => other !== candidate && other.contains(candidate)));
100
+ if (outer.length === 1) list = outer[0];
101
+ }
102
+ if (list) lists.add(list);
103
+ }
104
+ if (lists.size !== 1) return {};
105
+ const list = [...lists][0];
106
+ if (!listIds.has(list)) {
107
+ listIds.set(list, String(nextListId++));
108
+ // A frame the capture cannot open elsewhere on the page, such as an advertisement, cannot hold a member of a
109
+ // list whose element contains no frame; one inside the list, or behind a shadow root the list or anything in it
110
+ // holds, could.
111
+ const framed = e => e.tagName === 'IFRAME' || e.tagName === 'FRAME' || !!e.shadowRoot;
112
+ listFrameless.set(list, !framed(list) && ![...list.querySelectorAll('*')].some(framed));
113
+ }
114
+ const members = [...list.querySelectorAll('li,[role="listitem"]')].filter(member => {
115
+ for (let parent = member; parent && parent !== list; parent = parent.parentElement) {
116
+ if (hidden(parent)) return false;
117
+ }
118
+ return !!textOf(member);
119
+ });
120
+ return { list_id: listIds.get(list), list_count: members.length, list_frameless: listFrameless.get(list) };
121
+ };
122
+ const push = (kind, text, extra = {}, nodes = []) => {
38
123
  if (text)
39
- blocks.push({ kind, text, heading_path: [...path], frame_path: framePath, source_path: sourcePath, ...extra });
124
+ blocks.push({ kind, text, heading_path: [...path], frame_path: framePath, source_path: sourcePath, control_context: controlOf(nodes), ...listContext(nodes), ...extra });
40
125
  };
41
126
 
42
127
  // 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 +253,8 @@
168
253
  let headers = rows.filter(({ row }) => row.parentElement.tagName === 'THEAD');
169
254
  if (!headers.length && rows[0].cells.every(c => c.tagName === 'TH')) headers = [rows[0]];
170
255
  const line = ({ cells }) => '| ' + cells.map(c => cellText(c).replace(/\|/g, '\\|')).join(' | ') + ' |';
256
+ // Headerless comparison rows need leading cells for identity and separate source spans for counting.
257
+ if (!headers.length) return rows.map((row, index) => (index ? [line(rows[0]), line(row)].join('\n') : line(row)));
171
258
  const prefix = headers.map(line);
172
259
  if (headers.length) prefix.push('| ' + headers[0].cells.map(() => '---').join(' | ') + ' |');
173
260
  const data = rows.filter(row => !headers.includes(row));
@@ -184,8 +271,8 @@
184
271
  elements[0].href &&
185
272
  clean(elements[0].innerText ?? '') === text;
186
273
  for (const e of elements) text = withRating(text, e);
187
- if (onlyLink) push('link', text, { href: hrefOf(elements[0]) });
188
- else push('paragraph', text);
274
+ if (onlyLink) push('link', text, { href: hrefOf(elements[0]) }, run);
275
+ else push('paragraph', text, {}, run);
189
276
  for (const e of elements) for (const field of fields(e)) push('paragraph', field);
190
277
  run.length = 0;
191
278
  }
@@ -197,13 +284,22 @@
197
284
  // A classless div repeated three times is usually page layout; a record is a styled unit or a semantic item.
198
285
  function repeated(el) {
199
286
  if (!el.classList.length && el.tagName !== 'ARTICLE' && el.tagName !== 'LI') return false;
200
- return (
201
- [...(el.parentNode?.children ?? [])].filter(
287
+ const siblings = [...(el.parentNode?.children ?? [])].filter(
202
288
  sibling =>
203
289
  sibling.tagName === el.tagName &&
204
290
  sibling.classList.length === el.classList.length &&
205
291
  [...el.classList].every(token => sibling.classList.contains(token)),
206
- ).length >= 3
292
+ );
293
+ if (siblings.length >= 3) return true;
294
+ // Linked cards can sit inside an unlabelled list wrapper, so distinct destinations establish the pair.
295
+ if (siblings.length === 2 && el.tagName === 'A' && siblings.every(sibling => sibling.hasAttribute('href'))) {
296
+ return new Set(siblings.map(sibling => sibling.href)).size === siblings.length;
297
+ }
298
+ // A styled pair can be a two-column layout, so require a heading outside both items to name their group.
299
+ const section = el.parentElement;
300
+ return (
301
+ siblings.length === 2 &&
302
+ [...(section?.querySelectorAll('h1,h2,h3,h4,h5,h6') ?? [])].some(heading => siblings.every(sibling => !sibling.contains(heading)))
207
303
  );
208
304
  }
209
305
 
@@ -237,7 +333,7 @@
237
333
  continue;
238
334
  }
239
335
  if (SKIP.has(node.tagName)) continue;
240
- if (!isBlock(node) && recordText(node) === null) {
336
+ if (!node.matches('nav,[role="navigation"]') && !isBlock(node) && recordText(node) === null) {
241
337
  run.push(node);
242
338
  continue;
243
339
  }
@@ -248,30 +344,38 @@
248
344
  }
249
345
 
250
346
  function block(el) {
347
+ if (el.matches('nav,[role="navigation"]')) {
348
+ // Navigation help headings describe their menu, not product facts in the following main content.
349
+ const surrounding = [...path];
350
+ walk(el);
351
+ path = surrounding;
352
+ return;
353
+ }
251
354
  const level = HEADINGS[el.tagName];
252
355
  if (level) {
253
356
  const text = textOf(el);
254
357
  while (path.length >= level) path.pop();
255
358
  if (text) {
256
- push('heading', text);
359
+ push('heading', text, {}, [el]);
257
360
  path.push(text);
258
361
  }
259
362
  return;
260
363
  }
261
364
  if (el.tagName === 'TABLE') {
262
- for (const text of renderTable(el)) push('table', text);
365
+ for (const text of renderTable(el)) push('table', text, {}, [el]);
263
366
  // A form laid out in a table: its cells render as text, which holds none of the controls' values.
264
367
  for (const field of fields(el)) push('paragraph', field);
265
368
  return;
266
369
  }
267
- if (el.tagName === 'PRE') return push('code', textOf(el));
370
+ if (el.tagName === 'PRE') return push('code', textOf(el), {}, [el]);
268
371
  const record = recordText(el);
269
372
  if ((el.tagName === 'LI' && leaf(el)) || record !== null) {
270
- const links = el.querySelectorAll('a[href]');
373
+ const links = el.matches('a[href]') ? [el] : el.querySelectorAll('a[href]');
271
374
  push(
272
375
  el.tagName === 'LI' ? 'list_item' : 'record',
273
376
  record ?? withRating(textOf(el), el),
274
377
  links.length === 1 ? { href: hrefOf(links[0]) } : {},
378
+ [el],
275
379
  );
276
380
  for (const field of fields(el)) push('paragraph', field);
277
381
  return;
@@ -296,7 +400,7 @@
296
400
  } catch {
297
401
  inner = null;
298
402
  }
299
- if (!inner?.body) {
403
+ if (!inner?.body || !granted(inner)) {
300
404
  inaccessible++;
301
405
  return;
302
406
  }
@@ -310,6 +414,77 @@
310
414
  framePath = frame;
311
415
  sourcePath = source;
312
416
  walk(root);
417
+ path = [...headings];
418
+ const visible = e => e.checkVisibility ? e.checkVisibility({ visibilityProperty: true }) : !hidden(e);
419
+ for (const e of [...root.querySelectorAll('input,select,textarea,button,[role="button"]')].filter(visible)) {
420
+ if (controlObservations >= 64) {
421
+ if (!controlsOmitted) push('observation', 'Additional control state observations omitted.');
422
+ controlsOmitted = true;
423
+ break;
424
+ }
425
+ // Placeholders suggest input; they do not label a value or prove an empty named field.
426
+ if (CONTROLS.has(e.tagName) && !e.labels?.length && !e.hasAttribute('aria-label') && !e.hasAttribute('aria-labelledby')) continue;
427
+ const name = nameOf(e) || clean(e.innerText ?? '');
428
+ if (!name || e.dataset?.fastbrowseSecret === '1') continue;
429
+ if (CONTROLS.has(e.tagName) && !UNFILLED.has(e.type) && !fieldValue(e)) {
430
+ push('observation', `${name.slice(0, 1024)}: [empty]`);
431
+ controlObservations++;
432
+ }
433
+ if (controlObservations < 64 && e.matches(':disabled,[aria-disabled="true"]')) {
434
+ push('observation', `${name.slice(0, 1024)}: disabled`);
435
+ controlObservations++;
436
+ }
437
+ }
438
+ // Images carry no innerText, so a text-only capture cannot distinguish their presence from absence.
439
+ for (const image of [...root.querySelectorAll('img')].filter(visible)) {
440
+ if (imageObservations++ >= 32) {
441
+ if (!imagesOmitted) push('observation', 'Additional visible image metadata omitted.');
442
+ imagesOmitted = true;
443
+ break;
444
+ }
445
+ // Loading changes without a page edit, so captures report presence rather than transient decode state.
446
+ const metadata = { alt: image.getAttribute('alt')?.slice(0, 1024) ?? null,
447
+ src: imageSource(image.getAttribute('src') ?? image.getAttribute('srcset')) };
448
+ push('observation', `Image element DOM metadata: ${JSON.stringify(metadata)}. Pixels are not described.`);
449
+ }
450
+ // A link drawn only as an icon has no text, so its destination, the one thing it says, was nowhere a reader
451
+ // could quote: asked for a release's download URL beside an icon-only download link, a run read every other
452
+ // field of the row and then clicked the link again and again looking for the address. The row's text names
453
+ // which item the link belongs to, since the observation is not read where the link stands.
454
+ for (const link of [...root.querySelectorAll('a[href]')].filter(visible)) {
455
+ if (clean(link.innerText ?? '')) continue;
456
+ // An icon button written as `href="#"` or a script URL runs code here and leads nowhere to report. A bare
457
+ // `#` parses to an empty hash, so the raw attribute says it is a fragment link, not the parsed URL.
458
+ let target;
459
+ try {
460
+ target = new URL(link.getAttribute('href'), location.href);
461
+ } catch {
462
+ // One malformed address must not cost the page its whole capture.
463
+ continue;
464
+ }
465
+ const page = location.href.split('#')[0];
466
+ const fragment = link.getAttribute('href').trim().startsWith('#') || (target.hash && target.href.split('#')[0] === page);
467
+ if (target.protocol === 'javascript:' || fragment) continue;
468
+ if (linkObservations++ >= 32) {
469
+ if (!linksOmitted) push('observation', 'Additional link destinations omitted.');
470
+ linksOmitted = true;
471
+ break;
472
+ }
473
+ let row = link.parentElement;
474
+ while (row && row !== root && !clean(row.innerText ?? '')) row = row.parentElement;
475
+ const metadata = {
476
+ label: (nameOf(link) || drawn(link)).slice(0, 256) || null,
477
+ href: imageSource(link.href),
478
+ near: row ? textOf(row).replace(/\s+/g, ' ').slice(0, 256) || null : null,
479
+ };
480
+ push('observation', `Link element DOM metadata: ${JSON.stringify(metadata)}. The link has no text.`);
481
+ }
482
+ const h1 = [...root.querySelectorAll('h1,[role="heading"][aria-level="1"]')].filter(visible);
483
+ visibleH1 ||= h1.length > 0;
484
+ if (root === document.body) {
485
+ const doc = document.documentElement;
486
+ push('observation', `Observed viewport: Horizontal overflow: ${doc.scrollWidth > doc.clientWidth ? 'yes' : 'no'}`);
487
+ }
313
488
  for (const e of root.querySelectorAll('*')) {
314
489
  if (e.shadowRoot) scope(e.shadowRoot, frame, `${source}/shadow:${identity(e)}`);
315
490
  // A frame the walk never reached (hidden, or inside a record or table read as one block) is still read.
@@ -319,5 +494,6 @@
319
494
  }
320
495
 
321
496
  scope(document.body, null, '');
322
- return { url: location.href, title: document.title, blocks, inaccessible_frames: inaccessible };
323
- })()
497
+ if (!visibleH1) push('observation', 'Observed accessible DOM: Visible h1 headings: 0');
498
+ return { url: location.href, title: document.title, blocks, inaccessible_frames: inaccessible, visible_h1: visibleH1 };
499
+ })
@@ -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.
@@ -30,6 +41,10 @@
30
41
  return true;
31
42
  };
32
43
  const registry = (window.__fastbrowse ||= { ids: new WeakMap(), nodes: new Map(), next: 1 });
44
+ // Keep the page's own timer overrides from holding a browser-side frame wait open.
45
+ registry.nativeSetTimeout ||= window.setTimeout.bind(window);
46
+ registry.nativeClearTimeout ||= window.clearTimeout.bind(window);
47
+ registry.nativeRequestAnimationFrame ||= window.requestAnimationFrame.bind(window);
33
48
  // What was offered to be acted on; `ids` also names hover targets and fingerprinted nodes.
34
49
  registry.controls ||= new WeakSet();
35
50
  // A visible loading indicator means the page is still fetching what it will draw, which network idle and a
@@ -117,7 +132,7 @@
117
132
  } catch {
118
133
  inner = null;
119
134
  }
120
- if (inner?.body) include(inner);
135
+ if (inner?.body && granted(inner)) include(inner);
121
136
  }
122
137
  }
123
138
  };
@@ -165,10 +180,56 @@
165
180
  registry.visible = visible;
166
181
  // Styled checkboxes and radios often hide the native input. Its visible label is the click
167
182
  // 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);
183
+ const sourceOf = e => (e.tagName === 'LABEL' && ['checkbox', 'radio', 'file'].includes(e.control?.type) ? e.control : e);
169
184
 
170
185
  // Their text is code, not a name: Amazon nests a <style> inside a result card's link.
171
186
  const CODE = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE']);
187
+ // Icon-only buttons hide their glyph from accessibility text, but the icon library still names it: in a class,
188
+ // a sprite reference, a data attribute, an svg title or a ligature. A gallery's trash control otherwise reads
189
+ // "button" beside an edit link that reads "link", and the choice cannot tell removing an image from editing it.
190
+ const ICON_CLASS =
191
+ /^(?:icon-tabler|tabler-icon|lucide|glyphicon|fa|fas|far|bi|mdi|icon|ti|la|ri|ion|feather)-([a-z][a-z0-9-]{0,47})$/;
192
+ // Sizes, weights and effects share an icon library's prefix without naming a glyph, as does the library itself.
193
+ const ICON_STYLE =
194
+ /^(?:icon|tabler|solid|regular|light|thin|duotone|brands|xs|sm|lg|xl|\d+x|fw|spin|pulse|border|inverse|stack.*|pull-.*|rotate-.*|flip-.*)$/;
195
+ const glyphName = text => {
196
+ const name = (text || '').trim().toLowerCase().replace(/^(?:icons?|ico|ic|svg|sprite)[-_]/, '');
197
+ return /^[a-z][a-z0-9_-]{0,47}$/.test(name) && !ICON_STYLE.test(name) ? name : '';
198
+ };
199
+ const iconLabel = e => {
200
+ const glyphs = new Set();
201
+ for (const icon of e.querySelectorAll('svg,i,span,[role="img"]')) {
202
+ if (!icon.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) continue;
203
+ const named = [];
204
+ for (const token of icon.classList) {
205
+ const glyph = ICON_CLASS.exec(token);
206
+ if (glyph && !ICON_STYLE.test(glyph[1])) named.push(glyph[1]);
207
+ }
208
+ const use = icon.localName === 'svg' && icon.querySelector('use');
209
+ const sprite = use && (use.getAttribute('href') || use.getAttribute('xlink:href') || '');
210
+ if (sprite) named.push(glyphName(sprite.split('#').pop().split('/').pop().replace(/\.svg$/, '')));
211
+ named.push(glyphName(icon.getAttribute('data-icon')));
212
+ // A ligature font draws the glyph its text names, and the icon is hidden so readers skip that word.
213
+ if ([...icon.classList].some(token => /^material-(?:icons|symbols)/.test(token))) {
214
+ named.push(glyphName(icon.textContent.replace(/\s+/g, '_')));
215
+ }
216
+ const title =
217
+ icon.getAttribute('aria-label') || (icon.localName === 'svg' ? icon.querySelector(':scope > title')?.textContent : '');
218
+ if (title?.trim()) glyphs.add(title.replace(/\s+/g, ' ').trim());
219
+ else for (const name of named) if (name) glyphs.add(`${name.replace(/[-_]/g, ' ')} icon`);
220
+ }
221
+ return glyphs.size === 1 ? [...glyphs][0] : '';
222
+ };
223
+ // An accessible description is what an unnamed control has left that says what it does.
224
+ const describedBy = e => {
225
+ const root = typeof e.getRootNode === 'function' ? e.getRootNode() : document;
226
+ return (e.getAttribute('aria-describedby') || '')
227
+ .split(/\s+/)
228
+ .map(id => (id && (root.getElementById ? root.getElementById(id) : document.getElementById(id))?.textContent) || '')
229
+ .map(text => text.replace(/\s+/g, ' ').trim())
230
+ .filter(Boolean)
231
+ .join(' ');
232
+ };
172
233
  const labelOf = (e, seen = new Set()) => {
173
234
  if (!e || seen.has(e)) return '';
174
235
  seen.add(e);
@@ -233,7 +294,7 @@
233
294
  const DATE_TYPES = ['date', 'datetime-local', 'month', 'week', 'time'];
234
295
 
235
296
  const roleOf = e => {
236
- if (sourceOf(e) !== e) return sourceOf(e).type;
297
+ if (sourceOf(e) !== e) return sourceOf(e).type === 'file' ? 'textbox' : sourceOf(e).type;
237
298
  const explicit = e.getAttribute('role');
238
299
  if (ARIA_ROLES.includes(explicit)) return explicit;
239
300
  if (e.tagName === 'BUTTON' || e.tagName === 'SUMMARY') return 'button';
@@ -259,6 +320,7 @@
259
320
 
260
321
  const submitSemantics = e => {
261
322
  const form = e.form;
323
+ const explicit = ['BUTTON', 'INPUT'].includes(e.tagName) && ['submit', 'image'].includes(e.type);
262
324
  const implicit = [
263
325
  'text',
264
326
  'search',
@@ -273,8 +335,8 @@
273
335
  'datetime-local',
274
336
  'number',
275
337
  ];
276
- if (!form || e.tagName !== 'INPUT' || !implicit.includes(e.type)) return null;
277
- const submit = [...form.getRootNode().querySelectorAll('button,input')].find(
338
+ if (!form || (!explicit && (e.tagName !== 'INPUT' || !implicit.includes(e.type)))) return null;
339
+ const submit = explicit ? e : [...form.getRootNode().querySelectorAll('button,input')].find(
278
340
  c => c.form === form && (c.type === 'submit' || c.type === 'image'),
279
341
  );
280
342
  if (submit?.matches(':disabled')) return null;
@@ -311,7 +373,7 @@
311
373
  } catch {
312
374
  inner = null;
313
375
  }
314
- if (inner && inner.body) yield* walk(inner);
376
+ if (inner && inner.body && granted(inner)) yield* walk(inner);
315
377
  else inaccessible++;
316
378
  }
317
379
  }
@@ -342,11 +404,15 @@
342
404
  e.ownerDocument.location.origin,
343
405
  e.ownerDocument.defaultView.performance.timeOrigin,
344
406
  submitSemantics(e),
407
+ source.getAttribute('name'),
408
+ source.getAttribute('autocomplete'),
345
409
  identity(source),
346
410
  ];
347
411
  };
348
412
 
349
413
  const controls = [];
414
+ // Controls with no accessible name of their own, whose label is an icon's or only their role.
415
+ const unnamed = new Set();
350
416
  const fieldScope = e => {
351
417
  if (!e.matches('input,textarea,select')) return null;
352
418
  const form = e.form || e.closest('form,fieldset,[role="form"]');
@@ -376,16 +442,28 @@
376
442
  rname = roleOf(e);
377
443
  if (!rname || r.width <= 0 || r.height <= 0 || x < 0 || x >= innerWidth) continue;
378
444
  if (rname === 'gridcell' && e.querySelector('button,[role="button"]')) continue;
445
+ // A button-styled link offers the same label twice, but its inner button owns every useful click point.
446
+ if (rname === 'link') {
447
+ const nested = e.querySelectorAll(SELECTOR);
448
+ const button = nested.length === 1 ? nested[0] : null;
449
+ if (button?.tagName === 'BUTTON' && visible(button) && !button.matches(':disabled') &&
450
+ !button.closest('[aria-disabled="true"],[inert]') && labelOf(button) === labelOf(e)) continue;
451
+ }
452
+
379
453
  const id = identity(e);
380
454
  registry.controls.add(e);
455
+ const named = labelOf(e);
456
+ if (!named) unnamed.add(id);
381
457
  const base = {
382
458
  id,
383
459
  role: rname,
384
- label: labelOf(e) || rname,
460
+ label: named || iconLabel(e) || describedBy(e) || rname,
385
461
  offscreen: y < 0 || y >= innerHeight,
386
462
  distance: y < 0 || y >= innerHeight ? 1 + Math.abs(y - innerHeight / 2) : 0,
387
463
  sensitive: secret(source),
388
464
  input_type: source.type || null,
465
+ input_name: source.getAttribute('name') || null,
466
+ autocomplete: source.getAttribute('autocomplete') || null,
389
467
  frame_origin: e.ownerDocument.location.origin,
390
468
  frame_path: framePath(e.ownerDocument),
391
469
  form_id: fieldScope(source),
@@ -396,6 +474,8 @@
396
474
  base.href = u.origin === location.origin ? u.pathname + u.search : u.host + u.pathname;
397
475
  // The page's own word for "the next page of this list", which survives an icon label or another language.
398
476
  if (e.rel && e.rel.split(/\s+/).includes('next')) base.next_page = true;
477
+ // The page's own word that this link saves a file, which a task ruling out downloads has to see before a click.
478
+ if (e.hasAttribute('download')) base.download = true;
399
479
  }
400
480
  for (const key of ['checked', 'selected', 'expanded']) {
401
481
  const value = e.getAttribute('aria-' + key);
@@ -419,7 +499,7 @@
419
499
  base.operations = ['select'];
420
500
  base.options = [...e.options].filter(o => !o.disabled && !o.closest('optgroup[disabled]')).map(o => o.label);
421
501
  base.value = [...e.selectedOptions].map(o => o.label).join(', ');
422
- } else if (e.type === 'file') {
502
+ } else if (source.type === 'file') {
423
503
  base.operations = ['upload'];
424
504
  base.value = null;
425
505
  } else {
@@ -496,6 +576,9 @@
496
576
  for (let e = element.parentElement; e && e !== e.ownerDocument.body; e = e.parentElement) {
497
577
  if (twins.some(twin => twin !== element && e.contains(twin))) break;
498
578
  scope = e;
579
+ // A lone open reply has no form twin nearby; crossing its record picks a different commenter's name.
580
+ if (e.matches('article,li,[role="listitem"]') ||
581
+ (e.matches('tr,[role="row"]') && e.querySelector('input,textarea,select,[role="textbox"]'))) break;
499
582
  }
500
583
  return scope ? excerpt(nameOf(scope, element, label, nearest), CONTROL_CONTEXT_CHARS) : '';
501
584
  };
@@ -503,7 +586,7 @@
503
586
  // operation. An element whose hover rule would reveal something now hidden is offered as a hover target.
504
587
  const docs = [];
505
588
  const addDoc = doc => {
506
- if (!doc?.body || docs.includes(doc)) return;
589
+ if (!doc?.body || !granted(doc) || docs.includes(doc)) return;
507
590
  docs.push(doc);
508
591
  for (const frame of doc.querySelectorAll('iframe,frame')) {
509
592
  try {
@@ -581,12 +664,14 @@
581
664
  } catch {
582
665
  continue;
583
666
  }
584
- if (!hidden) continue;
667
+ // Removing the active hover target renumbers its identical siblings and sends the next choice
668
+ // to a different element. Keep it offered while the pointer still reveals its content.
669
+ if (!hidden && !e.matches(':hover')) continue;
585
670
  const image = e.querySelector('img[alt]');
586
671
  const label =
587
- firstLine(e.innerText) ||
588
672
  e.getAttribute('aria-label') ||
589
673
  image?.getAttribute('alt') ||
674
+ firstLine(e.innerText) ||
590
675
  e.getAttribute('title') ||
591
676
  e.tagName.toLowerCase();
592
677
  const role =
@@ -664,6 +749,58 @@
664
749
  }
665
750
  }
666
751
 
752
+ // An unnamed control the page draws once per card, a trash icon beside each image, is a twin only while another
753
+ // card shows the same icon: one left alone, or drawn by another icon set, would name no card and leave the choice
754
+ // guessing which image it removes. It is named by the card that holds it, or failing one by the nearest ancestor
755
+ // that says anything besides it.
756
+ // A card is an item, a row, or an element the page repeats beside itself with the same tag and class.
757
+ const isCard = e =>
758
+ e.matches('article,li,[role="listitem"],tr,[role="row"]') ||
759
+ (!!e.getAttribute('class') &&
760
+ [...(e.parentElement?.children || [])].some(
761
+ s => s !== e && s.localName === e.localName && s.getAttribute('class') === e.getAttribute('class'),
762
+ ));
763
+ const nearestName = (element, label) => {
764
+ let first = '';
765
+ for (let e = element.parentElement, path = element; e && e !== e.ownerDocument.body; path = e, e = e.parentElement) {
766
+ first ||= nameOf(e, element, label, true);
767
+ if (!isCard(e)) continue;
768
+ // An action row's own caption ("Actions") is the first text the climb meets and names no item, so the card
769
+ // is named by what it holds outside the row that holds the control.
770
+ const aria = (e.getAttribute('aria-label') || '').trim();
771
+ const outside = [...e.children]
772
+ .filter(child => child !== path)
773
+ .map(child => firstLine(child.innerText))
774
+ .find(line => line && line !== label && !repeated.has(line));
775
+ return excerpt((aria !== label && aria) || outside || first, CONTROL_CONTEXT_CHARS);
776
+ }
777
+ return excerpt(first, CONTROL_CONTEXT_CHARS);
778
+ };
779
+ for (const c of controls) {
780
+ if (!c.context && unnamed.has(c.id)) {
781
+ const context = nearestName(registry.nodes.get(c.id), c.label);
782
+ if (context) c.context = context;
783
+ }
784
+ }
785
+
786
+ // A revealed caption needs its actual container association, not merely the most recent action.
787
+ registry.hoverContexts = new WeakMap();
788
+ for (const c of controls) {
789
+ if (!c.operations.includes('hover')) continue;
790
+ const element = registry.nodes.get(c.id);
791
+ registry.hoverContexts.set(element, {
792
+ control: {
793
+ role: c.role, label: c.label,
794
+ context: /^\d+ of \d+$/.test(c.context || '') ? c.context : null,
795
+ },
796
+ labelAttributes: [element.getAttribute('role'), element.getAttribute('aria-label'),
797
+ element.getAttribute('title'), element.querySelector('img[alt]')?.getAttribute('alt') || null],
798
+ text: element.innerText,
799
+ parent: element.parentNode,
800
+ siblings: [...(element.parentNode?.children || [])],
801
+ });
802
+ }
803
+
667
804
  // A date field's own label ("Start Date") rarely says which picker it belongs to when a page offers several
668
805
  // ("Date Picker 3"): unlike a colliding label, a uniquely named one never reaches the loop above, so the
669
806
  // nearest ancestor that names a section is attached here on its own, without the twin-avoidance `contextOf`
@@ -777,7 +914,7 @@
777
914
  } catch {
778
915
  inner = null;
779
916
  }
780
- if (!inner) continue;
917
+ if (!inner || !granted(inner)) continue;
781
918
  const f = frame.getBoundingClientRect();
782
919
  readText(inner, dx + f.left + frame.clientLeft, dy + f.top + frame.clientTop);
783
920
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fastbrowse
3
- Version: 0.5.19
3
+ Version: 0.5.23
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
@@ -9,6 +9,7 @@ Author: Agent Labs
9
9
  License-Expression: MIT
10
10
  License-File: LICENSE
11
11
  License-File: NOTICE
12
+ Classifier: Development Status :: 4 - Beta
12
13
  Requires-Python: >=3.13
13
14
  Requires-Dist: cdp-use==1.4.5
14
15
  Requires-Dist: httpx[http2]<1,>=0.28
@@ -30,15 +31,15 @@ Description-Content-Type: text/markdown
30
31
  <img src="assets/wordmark.svg" alt="fastbrowse" width="360">
31
32
  </picture>
32
33
 
33
- **Browser automation with results you can verify.**
34
+ **Give your agent a browser.**
34
35
 
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.
36
+ Run browser tasks locally or in a cloud browser. Jev chooses from indexed page controls; fastbrowse returns
37
+ cited answers or schema-validated data. Code controls credentials, authorization and spending.
37
38
 
38
39
  [![pypi](https://img.shields.io/pypi/v/fastbrowse?style=flat-square&color=6366F1)](https://pypi.org/project/fastbrowse/)
39
40
  ![python](https://img.shields.io/badge/python-3.13%20%7C%203.14-475569?style=flat-square)
40
41
  ![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)
42
+ ![status](https://img.shields.io/badge/status-beta-6366F1?style=flat-square)
42
43
 
43
44
  </div>
44
45
 
@@ -47,53 +48,26 @@ Jev chooses actions from the page; code controls credentials, authorization and
47
48
  ## Why
48
49
 
49
50
  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.
51
+ something that was never on the page. Factual claims in a complete answer cite verbatim quotes from the page.
51
52
 
52
53
  ![fastbrowse signing in to a shop, adding two products, filling the shipping form and placing the order](docs/assets/demo.gif)
53
54
 
54
- 13 steps in 20.9s on local Chrome, shown 1.4x faster with pauses cut.
55
+ Recorded on local Chrome: 13 steps, 20.9s and $0.0034 in model calls. Playback is 1.4x faster with pauses cut.
55
56
 
56
57
  ### Against Browser Use agents
57
58
 
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.
59
+ The [benchmark page](https://fastbrowse.ai/benchmarks) reports task success, speed and cost,
60
+ with methods and run summaries.
62
61
 
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.
62
+ Rows are comparable only at matching task versions. See [eval results and workflow](docs/evals.md).
65
63
 
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).
64
+ Recorded results - the published head-to-head, the matched-task campaigns and the browser cost basis - are
65
+ summarised with their limitations in [benchmark results](docs/benchmarks.md).
92
66
 
93
67
  ### Why fastbrowse, against each kind of agent
94
68
 
95
69
  - **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.
70
+ 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
71
  - **Choice-model navigators** ([Browser Use Ultrafast](https://github.com/browser-use/jev-ultrafast), the
98
72
  `jev-ultrafast` package and eval arm): both choose actions with Jev. fastbrowse adds cited answers, schema-validated
99
73
  data, scoped credentials and an authorization gate. Navigation tasks compare the page each run ended on.
@@ -113,19 +87,24 @@ A Node program needs neither uv nor Python: see [Use it from JavaScript](#use-it
113
87
 
114
88
  ```sh
115
89
  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
90
+ # export AI_GATEWAY_API_KEY=... # Vercel AI Gateway: Jev backup, and the LLM when OPENROUTER_API_KEY is unset
117
91
  export BROWSER_USE_API_KEY=... # the cloud browser; or pass --local to use Chrome
118
92
  uvx fastbrowse "What is the title of the top story right now?" --start https://news.ycombinator.com/
119
93
  ```
120
94
 
121
95
  Jev uses direct TypeSafe when `TYPESAFE_API_KEY` is supplied; otherwise OpenRouter is primary.
122
96
  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.
97
+ automatic selection; see [provider routing](docs/jev.md#provider-failover). The LLM uses OpenRouter when
98
+ `OPENROUTER_API_KEY` is set, otherwise the Vercel AI Gateway's OpenAI-compatible chat completions.
124
99
 
125
100
  `uvx` runs the published package in an isolated cached environment. `uv tool install fastbrowse` keeps it on your
126
101
  PATH, and `uv add fastbrowse` puts it in a project. Service keys can live in a `.env` file in the working directory;
127
102
  [`.env.example`](.env.example) shows the settings. Values named by `--secret` must be in the process environment.
128
103
 
104
+ On Arch Linux, the [`fastbrowse` AUR package](https://aur.archlinux.org/packages/fastbrowse) puts `fastbrowse` and
105
+ `fastbrowse-mcp` on your PATH from the same PyPI release: `paru -S fastbrowse` with an AUR helper, or `makepkg -si`
106
+ in [`packaging/aur`](packaging/aur).
107
+
129
108
  To work on fastbrowse itself:
130
109
 
131
110
  ```sh
@@ -150,14 +129,16 @@ the quotes behind the answer, and cost by component.
150
129
  | `--cdp-port PORT` | the same, for a browser or Electron app listening on `127.0.0.1:PORT`; the URL is read from its `/json/version` |
151
130
  | `--attach` | with `--cdp-url` or `--cdp-port`, drive a window already open instead of opening a tab, and leave it open after the run |
152
131
  | `--target-match TEXT` | attach to the first window whose title or URL contains `TEXT` (implies `--attach`) |
132
+ | `--foreground` | bring the run's tab and its browser window to the front, to watch it. Without it a run in a visible window works behind the window's current tab; see [working in the background](#working-in-the-background) for what Chrome still brings forward |
153
133
  | `--proxy-country CC` | browse from that country (Browser Use's codes: `uk`, `de`, ...; default `us`), so a shop shows its local delivery and prices |
154
134
  | `--authorize` | allow submit, pay, delete and send; without it the run stops at `needs_confirmation` first |
155
- | `--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` |
135
+ | `--secret NAME=ENV_VAR[@ORIGIN]` | let the agent type `$ENV_VAR` on the declared origin (`https://*.site.com` covers its hosts), or the `--start` origin if omitted; models only see `NAME`. An explicit origin needs no `--start` |
156
136
  | `--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 |
137
+ | `--max-steps N`, `--max-dollars N` | optionally bound steps and model spend; defaults are unlimited. Cloud browser charges are added when it stops |
158
138
  | `--downloads DIR` | keep downloaded files |
159
139
  | `--json` | full result instead of the answer |
160
140
  | `--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 |
141
+ | `--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. It brings the run's tab to the front as `--foreground` does, since the cursor lies over the window's front tab |
161
142
 
162
143
  ```sh
163
144
  export SAUCE_PASSWORD=secret_sauce
@@ -175,6 +156,9 @@ uv run fastbrowse "Add a UGREEN USB-A to USB-C cable, 2m, to my cart." \
175
156
  --start https://www.amazon.com/ --profile ~/.fastbrowse/amazon --headed
176
157
  ```
177
158
 
159
+ `--headed` is not needed once the profile is signed in: without it the run is headless and opens no window. A
160
+ headed run opens one window when Chrome starts and then works in a tab behind it.
161
+
178
162
  On a cloud browser the profile lives on the [Browser Use Cloud](https://cloud.browser-use.com) account
179
163
  rather than on disk, and `--cloud-profile ID` runs as it. Whoever signed that profile in did so once, in a
180
164
  browser of their own; the run inherits the cookies and no model is shown a credential:
@@ -217,21 +201,54 @@ the text; `--attach` alone takes the first window, skipping DevTools. Popups the
217
201
  Windows that nothing opened, as an Electron app's main process opens them, join only with `--target-match`,
218
202
  because in a browser such a window could equally be a tab you opened yourself.
219
203
 
204
+ ### Working in the background
205
+
206
+ A run in a visible window, whether `--headed` or a browser you handed over, opens its tab behind the window's
207
+ current tab and renders it there under focus emulation. Nothing the run sends raises or focuses the window. `--foreground` brings the tab and its window to the front instead, to watch the run. Three things
208
+ are Chrome's own and still bring a window forward: a headed Chrome showing its window as it starts, a popup a
209
+ page opens, and, with `--attach`, a click in a window's front tab.
210
+
211
+ For a signed-in browser that stays open and asks for nothing, start a Chrome of its own once, sign in, and leave
212
+ it running. Each run then opens and closes a background tab in it:
213
+
214
+ ```sh
215
+ google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.fastbrowse/chrome"
216
+ uv run fastbrowse "Report my open orders." --start https://www.amazon.com/ --cdp-port 9222
217
+ ```
218
+
219
+ Chrome ignores `--remote-debugging-port` on its default data directory, so the directory of its own is required.
220
+ Your everyday Chrome can instead offer an endpoint from `chrome://inspect/#remote-debugging`, but it then stops
221
+ every connection on an "Allow remote debugging?" prompt that takes focus and waits for a click, once per run.
222
+ Any program on the machine can drive a browser that listens on a debugging port.
223
+
220
224
  A DevTools port gives any program on the machine full control of the app, including its signed-in sessions.
221
225
  Close the app, or restart it without the flag, when you are done.
222
226
 
223
227
  ### Models
224
228
 
225
229
  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.
230
+ `google/gemini-3.5-flash-lite` for proposing a direct address and typing field text.
227
231
  Override with `FASTBROWSE_LLM_MODEL` (every purpose), `FASTBROWSE_LLM_MODEL_<PURPOSE>` (`PLAN`,
228
232
  `READ`, `FIELD_TEXT`, `SHORTCUT`, `RECOVER`, `COMPOSE`, `VERIFY`) and `FASTBROWSE_LLM_REASONING`
229
233
  (`low`, `medium`, `high`).
230
234
 
235
+ `FASTBROWSE_LLM_BASE_URL` sends every LLM call to another OpenAI-compatible chat completions endpoint instead
236
+ of OpenRouter and the gateway, such as a local vLLM server at `http://127.0.0.1:8000/v1`. Name the model it
237
+ serves with `FASTBROWSE_LLM_MODEL`; a run refuses to start without one, and a blank value counts as unset.
238
+ OpenRouter's `reasoning` field is sent to that endpoint only when `FASTBROWSE_LLM_REASONING` is set, since a
239
+ strict server refuses a field it does not know. `FASTBROWSE_LLM_API_KEY` is its bearer
240
+ token, sent only when set, and that endpoint never receives `OPENROUTER_API_KEY` or `AI_GATEWAY_API_KEY`. Two
241
+ settings shape its requests: `FASTBROWSE_LLM_STRICT=0` sends each schema as the model defines it rather than
242
+ in strict form, for a server whose structured output breaks under strict mode, and `FASTBROWSE_LLM_EXTRA_BODY`
243
+ is a JSON object added to every request, such as `{"chat_template_kwargs":{"enable_thinking":false}}`. It may
244
+ not set a field fastbrowse sends itself (`model`, `messages`, `max_tokens`, `response_format`, `provider`,
245
+ `reasoning`). Jev still needs its own key. An endpoint that reports no price leaves those calls' cost unknown
246
+ rather than zero, so `--max-dollars` stops the run after the first such call.
247
+
231
248
  ### Results
232
249
 
233
250
  The exit code identifies the run status. Configuration refusals exit 1; invalid command syntax exits 2.
234
- Programs that only check zero versus nonzero continue to work.
251
+ Only `complete` exits 0, so a program can also check zero versus nonzero.
235
252
 
236
253
  | Status | Exit | Meaning |
237
254
  |:--|--:|:--|
@@ -242,14 +259,30 @@ Programs that only check zero versus nonzero continue to work.
242
259
  | `blocked` | 6 | a bot check (a CAPTCHA) that did not clear; not a sign-in, so no secret passes it |
243
260
  | `needs_input` | 5 | a required value or file is missing, or an upload exceeds the configured size limit |
244
261
  | `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 |
262
+ | `budget_exceeded` | 7 | a resource limit was reached, or a missing cost prevents enforcing the dollar cap |
246
263
  | `observation_limit` | 11 | the page or required evidence cannot fit the configured prompt budget |
247
264
  | `unavailable` | 8 | a model or browser provider stayed unavailable through every retry; the same run later may pass |
248
265
  | `error` | 1 | a model or browser failure |
249
266
 
250
267
  A `budget_exceeded` JSON result includes `budget.resource` (`steps`, `seconds`, `dollars`, `jev_calls` or
251
268
  `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.
269
+ optional object, so callers can identify the affected limit without parsing an error message.
270
+
271
+ A bot check can block a cloud browser before any task steps execute. Retry with an attached local browser
272
+ or choose another source for the research. Credentials do not resolve a CAPTCHA.
273
+ The `blocked` result's `evidence` quotes the opening text of the challenge page, with its address.
274
+
275
+ If a model completion omits its cost, fastbrowse looks up the matching OpenRouter or Vercel generation
276
+ receipt. If that receipt is still unavailable, a run with a dollar cap stops with `budget_exceeded`;
277
+ this does not mean the known charges reached the cap. Supplied `inputs` can provide exact field text
278
+ without a field-writing generation.
279
+
280
+ A browser command that remains unanswered for 120 seconds returns `unavailable`, even if the browser
281
+ still answers health checks. Step and dollar limits do not bound total elapsed time. Set `Limits(max_seconds=...)` for a run deadline.
282
+ When investigating a stalled run, enable trace logging and retain the last event alongside the result.
283
+
284
+ [Troubleshooting](docs/troubleshooting.md) takes each status a run can stop with: what it means, its common
285
+ causes and the next step.
253
286
 
254
287
  ## How it works
255
288
 
@@ -315,7 +348,8 @@ asyncio.run(main())
315
348
  the run opens its own tab and closes the tabs it owns. It leaves the browser and pre-existing tabs open;
316
349
  cookies and other changes made by the task can persist. `cdp_port=` finds the same browser from its DevTools
317
350
  port on `127.0.0.1`. `attach=True` and `target_match=` drive a window already open and leave it open, as
318
- `--attach` and `--target-match` do. Pass `browser_api_key=` to start a cloud browser;
351
+ `--attach` and `--target-match` do, and `foreground=True` brings the run's tab to the front as `--foreground`
352
+ does. Pass `browser_api_key=` to start a cloud browser;
319
353
  with neither argument, it runs local Chrome. Passing both is an error. `cloud_extensions=[...]` loads up to
320
354
  three of your account's ready Browser Use Cloud extensions, by ID, into that cloud browser; it is an error with
321
355
  local or attached browsers.
@@ -334,6 +368,17 @@ async def main() -> None:
334
368
 
335
369
  `resolve_cdp_port(port)` returns the `ws://` URL behind a DevTools port, for a caller that passes `cdp_url=`.
336
370
 
371
+ `run_task` and `connect_cdp` take two access controls that the command line, the MCP server and the JavaScript
372
+ SDK do not offer. `allowed_origins=["https://app.example.com"]` limits the documents a run may read and control
373
+ to those exact origins: scheme, host and optional port, with no wildcard or path. A navigation, redirect, frame
374
+ or popup outside the list is refused before it is sent, and a window already open outside it is never read.
375
+ A sign-in that redirects through another origin needs that origin listed too. The list scopes documents, not
376
+ the network: images, scripts and requests a listed page makes to other hosts still load. A scoped run delivers
377
+ no live frames or screenshots, and combining it with `record=` is an error. `check_access=` is an async callback
378
+ awaited before the browser starts and before every read, navigation and action. When it raises, `connect_cdp`
379
+ raises a `BrowserError` and `run_task` returns a `RunResult` with status `error`; either way the error names only
380
+ the exception's type, never its text.
381
+
337
382
  `RunResult.citations` is a tuple of `Citation` objects, also importable from `fastbrowse`. Each has `id`
338
383
  (the number in the answer), `text` (the Notes fact), `requirement_id` (or `None`), `url`, `quote` and
339
384
  `deep_link`. Each claim in `result.answer` carries numbered Markdown links to its supporting facts.
@@ -373,7 +418,7 @@ package is a TypeScript SDK with no dependencies. With it npm installs one of fi
373
418
  as a native binary, the one for your platform: `@fastbrowse/darwin-arm64`, `darwin-x64`, `linux-arm64`,
374
419
  `linux-x64` or `win32-x64`. No install script runs and nothing is downloaded on first use, so it installs under
375
420
  pnpm and bun with scripts blocked, and it starts offline. It needs Node.js 20 or newer. The npm packages carry
376
- the PyPI package's version and are published by the same tag, starting with the release after 0.5.18.
421
+ the PyPI package's version and are published by the same tag.
377
422
 
378
423
  The binary brings no browser. It finds local Chrome, starts a Browser Use Cloud browser, or drives one already
379
424
  running, as the command line does. It reads the same keys from the environment of your process
@@ -428,7 +473,7 @@ the options are camelCase. The structured data is in `data`, as in Python. The S
428
473
  schema's output type. Data the schema refuses rejects with `OutputValidationError`, which carries the result.
429
474
  A JSON Schema object is accepted too; `output` is then `data`, typed `unknown`.
430
475
 
431
- What a run can fill today is narrower than what a schema can say. The server accepts nested objects, arrays,
476
+ What a run can fill is narrower than what a schema can say. The server accepts nested objects, arrays,
432
477
  `enum`, `const` and optional values, and refuses any other keyword by name before a browser opens. A run
433
478
  fills a flat object of required string, number, integer and boolean fields, as the example has. With any
434
479
  other field the run ends `unverified` with no data.
@@ -449,11 +494,11 @@ await fb.run('Log in as standard_user with the saved password and add the backpa
449
494
 
450
495
  `secrets.resolve` is called each time a value is typed and never for an origin its ref does not cover, so the
451
496
  value leaves your vault at that moment and a one-time code is fresh. `until` gets the address the run ended on,
452
- and anything but `true` keeps the run from `complete`. `onFrame` receives JPEG frames of the active tab;
453
- without it no frame is sent. A callback that throws ends the run with status `error`. The other options are
454
- the command line's: `inputs`, `attachments` as bytes, `downloads`, `record`, and the browser choices
455
- `chrome`, `cloudProfile`, `cdpUrl`, `cdpPort`, `attach`, `targetMatch` and `proxyCountry`, which
456
- `Fastbrowse.start` also takes as defaults for every run. A run passes `null` for one of them to go without
497
+ and anything but `true` keeps the run from `complete`. `onFrame` receives JPEG frames of the active tab; without
498
+ it no frame is sent. A callback that throws ends the run with status `error`. The other options are `run_task`'s:
499
+ `inputs`, `attachments` as bytes, `downloads`, `record`, and the browser choices `chrome`, `cloudProfile`,
500
+ `cdpUrl`, `cdpPort`, `attach`, `targetMatch`, `foreground`, `proxyCountry`, `viewport` and `cloudAllowResizing`,
501
+ which `Fastbrowse.start` also takes as defaults for every run. A run passes `null` for one of them to go without
457
502
  that default.
458
503
 
459
504
  There is no binary for Alpine or another musl system, and none for a platform outside the five. There
@@ -462,6 +507,18 @@ There is no binary for Alpine or another musl system, and none for a platform ou
462
507
  that allows programs only by the team that signed them refuses. The Windows binary is not signed. Bun and Deno are
463
508
  untested.
464
509
 
510
+ ## Use fastbrowse with your agent
511
+
512
+ Install the [fastbrowse skill](docs/skill.md) to delegate website tasks from an agent that supports Agent Skills. It uses the MCP
513
+ tool when connected, or the CLI, and preserves the task's citations, status, authorization, and budget.
514
+ The guide covers agent selection, configuration, and example prompts for Codex and Claude Code.
515
+ Agents without skill support can use the CLI or MCP server directly.
516
+
517
+ ## Run fastbrowse through Nebula
518
+
519
+ [Nebula](https://www.nebula.gg/) embeds fastbrowse in its agent harness, with browser steps, results and
520
+ permitted vault logins. Use Bitwarden or 1Password through Nebula with credentials scoped to authorized websites.
521
+
465
522
  ## Use it from an MCP client
466
523
 
467
524
  `fastbrowse-mcp` serves one `browse` tool over [MCP](https://modelcontextprotocol.io), so Claude Code, Claude
@@ -493,12 +550,12 @@ The server's flags decide what a calling model may do; a call can ask for less,
493
550
 
494
551
  | Flag | Effect |
495
552
  |:--|:--|
496
- | `--local`, `--headed`, `--profile DIR`, `--downloads DIR` | as for the CLI, fixed for every call |
553
+ | `--local`, `--headed`, `--profile DIR`, `--foreground`, `--downloads DIR`, `--cursor` | as for the CLI, fixed for every call |
497
554
  | `--cloud-profile ID` | every call runs signed in as that cloud profile; a calling model cannot choose it |
498
555
  | `--allow-authorize` | let a call pass `authorize` to go through irreversible actions; without it they always stop at `needs_confirmation` |
499
556
  | `--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
557
  | `--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) |
558
+ | `--max-steps N`, `--max-dollars N`, `--max-seconds N` | optional ceilings per call; defaults are unlimited |
502
559
  | `--max-concurrent N` | runs at once, default 1; more calls wait their turn |
503
560
  | `--transport http`, `--host`, `--port` | streamable HTTP at `/mcp` instead of stdio |
504
561
 
@@ -521,9 +578,9 @@ seconds to minutes, so raise the client's tool timeout if it has one (`MCP_TOOL_
521
578
 
522
579
  ## Evals and development
523
580
 
524
- The [eval workflow](docs/evals.md#workflow) has the run, publish and regeneration commands. Final page checks
581
+ [Eval commands](docs/evals.md) cover running suites, publishing results and regenerating evidence. Final page checks
525
582
  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.
583
+ build and task versions; the [compact results feed](docs/results/summary.json) is generated from them.
527
584
 
528
585
  See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and checks, [docs/design.md](docs/design.md) for the browser
529
586
  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.23.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
5
+ fastbrowse-0.5.23.dist-info/METADATA,sha256=_tHtsDCpIq1LtYvyl1hsqA-XHnZB86idiCg6iqO6IMw,36424
6
+ fastbrowse-0.5.23.dist-info/RECORD,,
7
+ fastbrowse-0.5.23.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ fastbrowse-0.5.23.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ fastbrowse-0.5.23.dist-info/direct_url.json,sha256=cZGZY9ovdib3X-RhjHdlyO_0_EMUe0tW6AXsSyMbNE4,86
10
+ fastbrowse-0.5.23.dist-info/entry_points.txt,sha256=SlQAK1_4_oywTAMckx8BYuCQJva0F-QuShQPcxtUm-g,95
11
+ fastbrowse-0.5.23.dist-info/licenses/LICENSE,sha256=gcUazG_j5a2blKPmfKqRKE98KOqX2kPbWY-EOtQZiNU,1067
12
+ fastbrowse-0.5.23.dist-info/licenses/NOTICE,sha256=qEO0Az_Tk98vRVL0xjnGYgMvexlMCA92lWl4JBwYRzg,1854
13
+ fastbrowse-0.5.23.dist-info/uv_build.json,sha256=RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o,2
14
+ fastbrowse-0.5.23.dist-info/uv_cache.json,sha256=RHhZDSEpPRq1C_MkD2kEEppnHuQ4UybkBcfjm8erpLo,194
@@ -0,0 +1 @@
1
+ {"timestamp":{"secs_since_epoch":1792329370,"nanos_since_epoch":625328192},"commit":null,"tags":null,"env":{},"directories":{"src":{"secs_since_epoch":1792329370,"nanos_since_epoch":634544741}}}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: websockets
3
- Version: 17.1
3
+ Version: 17.2
4
4
  Summary: An implementation of the WebSocket Protocol (RFC 6455 & 7692)
5
5
  Author-email: Aymeric Augustin <aymeric.augustin@m4x.org>
6
6
  License-Expression: BSD-3-Clause
@@ -143,7 +143,7 @@ Why shouldn't I use ``websockets``?
143
143
  that build on top of ``websockets`` to support WebSocket connections, like
144
144
  uvicorn_ or Sanic_.
145
145
 
146
- .. _uvicorn: https://www.uvicorn.org/
146
+ .. _uvicorn: https://uvicorn.dev/
147
147
  .. _Sanic: https://sanic.dev/en/
148
148
 
149
149
  What else?
@@ -1,20 +1,20 @@
1
1
  ../../../bin/websockets,sha256=DVn8K_GAmc_NaykY80NWs5Rqx1AeOpPtmypr9Uuy4GQ,337
2
- websockets-17.1.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
3
- websockets-17.1.dist-info/METADATA,sha256=F8cPnGo0ogTyqWnHPkfrp3KJ7yopkUIIqxCuSZc-Gto,6340
4
- websockets-17.1.dist-info/RECORD,,
5
- websockets-17.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- websockets-17.1.dist-info/WHEEL,sha256=j0ILfIDPYGKSxi38ezJw4f9sIlVrNBnqDQjDYMVPBMs,138
7
- websockets-17.1.dist-info/entry_points.txt,sha256=Dnhn4dm5EsI4ZMAsHldGF6CwBXZrGXnR7cnK2-XR7zY,51
8
- websockets-17.1.dist-info/licenses/LICENSE,sha256=PWoMBQ2L7FL6utUC5F-yW9ArytvXDeo01Ee2oP9Obag,1514
9
- websockets-17.1.dist-info/top_level.txt,sha256=CMpdKklxKsvZgCgyltxUWOHibZXZ1uYIVpca9xsQ8Hk,11
2
+ websockets-17.2.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
3
+ websockets-17.2.dist-info/METADATA,sha256=DV8SedCV57qE7yFhSvCCLcUAzLLlG3w6Ge2nFZstreQ,6336
4
+ websockets-17.2.dist-info/RECORD,,
5
+ websockets-17.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ websockets-17.2.dist-info/WHEEL,sha256=j0ILfIDPYGKSxi38ezJw4f9sIlVrNBnqDQjDYMVPBMs,138
7
+ websockets-17.2.dist-info/entry_points.txt,sha256=Dnhn4dm5EsI4ZMAsHldGF6CwBXZrGXnR7cnK2-XR7zY,51
8
+ websockets-17.2.dist-info/licenses/LICENSE,sha256=PWoMBQ2L7FL6utUC5F-yW9ArytvXDeo01Ee2oP9Obag,1514
9
+ websockets-17.2.dist-info/top_level.txt,sha256=CMpdKklxKsvZgCgyltxUWOHibZXZ1uYIVpca9xsQ8Hk,11
10
10
  websockets/__init__.py,sha256=zmBCJj7EIa6ZJEQDp4dIdznfzAJUA8dW41-j7Em-QDQ,7450
11
11
  websockets/__main__.py,sha256=wu5N2wk8mvBgyvr2ghmQf4prezAe0_i-p123VVreyYc,62
12
12
  websockets/asyncio/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- websockets/asyncio/client.py,sha256=ds1rHzlGQyUoGSVSm0Ztc2QL5cLOiUK9957bO0K6-OY,30554
13
+ websockets/asyncio/client.py,sha256=WBGS7GrjeBrElotuw45kUC_xrP449yuip9TCBm1yFrE,30666
14
14
  websockets/asyncio/connection.py,sha256=8utq1WNx-rM4OaetJpTktWEL6oOb-Srh925tCmPx40Y,49740
15
15
  websockets/asyncio/messages.py,sha256=vBPd93IuL3LE0OSxeaICIjzvyoht53kmh-_Zho-bGzU,11096
16
16
  websockets/asyncio/router.py,sha256=JFNUNILkAB5P9ft6PLogNaZq6p92Wz19vIlxfx1DTlg,7783
17
- websockets/asyncio/server.py,sha256=34Rtp3g3nAWLDaBScxubofczkNoDIMMzMPAwdLFKxvw,34126
17
+ websockets/asyncio/server.py,sha256=9aq5Fv-b9gTHfIY1h56A4iNIm6Ber5IQAkcdVCExY3M,34434
18
18
  websockets/auth.py,sha256=U_Jwmn59ZRQ6EecpOvMizQCG_ZbAvgUf1ik7haZRC3c,568
19
19
  websockets/cli.py,sha256=fYf0YqrWIx_iJDA-RWdczh5vjO9p6ZxZvmE761bUQg0,6892
20
20
  websockets/client.py,sha256=6vTmYiCpIpe8eUrcmElOY-oKriH6OUxyp-hGpTWvPMc,16140
@@ -23,8 +23,8 @@ websockets/datastructures.py,sha256=XGTvvgFdZSevNV_T6-Lo-Dxm2VHxJcgKl4l1DNQ6fU8,
23
23
  websockets/exceptions.py,sha256=tp0_EyqQfd7CYNOHmxAieOH8Ed9PSQFt18e-CuReTOY,14308
24
24
  websockets/extensions/__init__.py,sha256=QkZsxaJVllVSp1uhdD5uPGibdbx_091GrVVfS5LXcpw,98
25
25
  websockets/extensions/base.py,sha256=JNfyk543C7VuPH0QOobiqKoGrzjJILje6sz5ILvOPl4,2903
26
- websockets/extensions/permessage_deflate.py,sha256=IAXdCGY60zfdjatKd6fDfTMizOeQYsnh0zBtL9vPuzA,25840
27
- websockets/frames.py,sha256=Twiqn_buvQiblERugcT-MDmnPZ7kt-FMXESe2DhOT9o,16709
26
+ websockets/extensions/permessage_deflate.py,sha256=2X1f3T5HuOQXbYnmHX-Mk3q1t21n-U_FhfjS6oHBibw,26569
27
+ websockets/frames.py,sha256=YssQbowj263xm7XnQAahjcRlA4Hdy1WSBYthsfx8EPs,17486
28
28
  websockets/headers.py,sha256=yQnPljVZwV1_V-pOSRKNLG_u827wFC1h72cciojcQ8M,16046
29
29
  websockets/http11.py,sha256=tLVuNvNADwt8d1ui5KMFKZCZMTtJ6CqXZveogLaPbf0,18245
30
30
  websockets/imports.py,sha256=T_B9TUmHoceKMQ-PNphdQQAH2XdxAxwSQNeQEgqILkE,2795
@@ -34,32 +34,32 @@ websockets/legacy/client.py,sha256=5oJurUczPddXi8Rde5fA2hD3V4pnv__Z64pRRXP6HBs,2
34
34
  websockets/legacy/exceptions.py,sha256=ViEjpoT09fzx_Zqf0aNGDVtRDNjXaOw0gdCta3LkjFc,1924
35
35
  websockets/legacy/framing.py,sha256=b0wMx83XTIXVZoO60pl3AibKHbQlDb7rBAyhFEkaMCk,6374
36
36
  websockets/legacy/handshake.py,sha256=2Nzr5AN2xvDC5EdNP-kB3lOcrAaUNlYuj_-hr_jv7pM,5285
37
- websockets/legacy/http.py,sha256=cOCQmDWhIKQmm8UWGXPW7CDZg03wjogCsb0LP9oetNQ,7061
37
+ websockets/legacy/http.py,sha256=5Ru5qH1dwJ9EDAh5-CCJaI7KAhrk5j7fVU_63y5KfeI,7151
38
38
  websockets/legacy/protocol.py,sha256=3MTh1-0ktWStRCX00lLV69gLzF_9gAn8YLFE_bZkzYw,63645
39
39
  websockets/legacy/server.py,sha256=hSk4nFTsI3eMRbu5JcI4gXei9Ubw07RSrSjtL4eM2gc,44835
40
40
  websockets/protocol.py,sha256=3HuCBMLTJMxp9JHd5JMBqtTF84SsY29iuHH0yznuD_Q,27204
41
- websockets/proxy.py,sha256=oFrbEYtasYWv-WDcniObD9nBR5Q5qkHpyCVLngx7WMQ,4969
41
+ websockets/proxy.py,sha256=gydvdaoJctxyGRZukd1vKimuxl4EYhT1ee9aPHqjkZM,4904
42
42
  websockets/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
43
43
  websockets/server.py,sha256=7kEJL1QEFO8pTB90wP4fUbkzYGDcbkTa7jTvdiSZtQk,24679
44
44
  websockets/speedups.c,sha256=u_dncR4M38EX6He_fzb1TY6D3Hke67ZpoHLLhZZ0hvQ,5920
45
- websockets/speedups.cpython-313-darwin.so,sha256=7k61j4nITTQTQKmNoi0s_8FW5ZRa_sZXSW8MqCKn9UM,9856
45
+ websockets/speedups.cpython-313-darwin.so,sha256=cQQscid5qO4S83zZAT_jYiOM1gecdZ5Sw_fS7eW15KU,9856
46
46
  websockets/speedups.pyi,sha256=unjvBNg-uW4c7z-9OW4WiSzZk_QH2bLEcjYAMuoSgBI,102
47
47
  websockets/streams.py,sha256=rJrDNSL9hy3EJYc1PFHjYx4rdd1vELCua0CwdFiHN_8,4610
48
48
  websockets/sync/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
49
- websockets/sync/client.py,sha256=WyD943lK7j4NQLI7PqoxWEEdd9mMIJjpa0NExx08Pj4,39916
50
- websockets/sync/connection.py,sha256=AZqADNZOE3ecTiqT7WNq1nsUbuojaDFpzNicEN9wIFQ,48680
49
+ websockets/sync/client.py,sha256=AmXZikxVg1m39yfl1Ovm9KY3xaTfzXeXNQ36yvYMHqQ,39972
50
+ websockets/sync/connection.py,sha256=3ZxMhZ9PzIzKzNFo8C78JLJWfjnh0QzPeMk6h_lP4EQ,49326
51
51
  websockets/sync/messages.py,sha256=q8K1vxdrqVk1bvn3zW1Jx09TlakIWSyYcOHC7ueFQmk,12826
52
52
  websockets/sync/router.py,sha256=wOGAvGbKwXyqmNEFBCWZP3faHvy_2MKN7O2XeEL8nGI,7212
53
- websockets/sync/server.py,sha256=VTT6TsR-J-NSWW5le8UVMTNNFNzPp6wbZWuVQUh-jYk,34422
53
+ websockets/sync/server.py,sha256=oxbJ9bs18SZOY2uSVrThdtLO8GOGyIJ5g4yFQcq-WFg,34882
54
54
  websockets/sync/utils.py,sha256=TtW-ncYFvJmiSW2gO86ngE2BVsnnBdL-4H88kWNDYbg,1107
55
55
  websockets/trio/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
56
- websockets/trio/client.py,sha256=m2N5IaFBrPYcUtp1VCPUo8tJ2UFi2fQ4G-95M4JCOxU,30197
56
+ websockets/trio/client.py,sha256=lR4uIlZ5Ak1gJ8Cbcg83G3p3PbkO00AMJQCun52FBbY,30256
57
57
  websockets/trio/connection.py,sha256=1XHDkVbK3e_M91pOGGeYeT0oazUM5NjqHOow0FkNrgY,49248
58
58
  websockets/trio/messages.py,sha256=SmbA1k_ljEGuEHZNJ0HPDe8-ojHtMTiXGR20HrPJuKk,10476
59
59
  websockets/trio/router.py,sha256=qZL9-44mi9SOOTGU3CLBPuaCNzafQGqZ-fJl9hVpv-s,7196
60
- websockets/trio/server.py,sha256=jRtrfZf3hHp1aQo6ffNMN7siaWzvZVhqC1iq1_Pj0sA,25805
60
+ websockets/trio/server.py,sha256=AnXSoUJceAyFTKqTcGDbajrEBt2vkDMokwWvWSXUu38,25934
61
61
  websockets/trio/utils.py,sha256=A12NYUPOjQaiGusak43oWzPIcCxhikKTkAJLXtZJvcY,956
62
62
  websockets/typing.py,sha256=cu89n_XQXno_g9qPpFmhLbbznE-MKVazUAows2u5JqU,1840
63
- websockets/uri.py,sha256=2fFMw-AbKJ5HVHNCuw1Rx1MnkCkNWRpogxWhhM30EU4,3125
63
+ websockets/uri.py,sha256=TNFgDvNDogfESw-78X8UDkh9pJF03ae-dujtpxE8BOs,3068
64
64
  websockets/utils.py,sha256=Wdtsh2qQiHLrXUBEnp3LatMLJTZjB2CErbeOFvrdXvE,2022
65
- websockets/version.py,sha256=-oLWt_FTqI6kCSgzMRlK9II17zZe7OnLw1nW2ZvqXEs,3202
65
+ websockets/version.py,sha256=FXV-IiFoGtln5A_i5aYSpGvbTBGiWUgRSsorpNv83ho,3202
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastbrowse/darwin-x64",
3
- "version": "0.5.19",
3
+ "version": "0.5.23",
4
4
  "description": "The fastbrowse binary for macOS on x64.",
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=s1_lKZNGOUwn2t1FIJzgmWECL4sJnQI15gAX4s4s70Y,194
@@ -1 +0,0 @@
1
- {"timestamp":{"secs_since_epoch":1791321495,"nanos_since_epoch":712749409},"commit":null,"tags":null,"env":{},"directories":{"src":{"secs_since_epoch":1791321495,"nanos_since_epoch":719168098}}}