sentienceapi 0.90.12__py3-none-any.whl → 0.92.2__py3-none-any.whl

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.

Potentially problematic release.


This version of sentienceapi might be problematic. Click here for more details.

Files changed (63) hide show
  1. sentience/__init__.py +14 -5
  2. sentience/_extension_loader.py +40 -0
  3. sentience/action_executor.py +215 -0
  4. sentience/actions.py +408 -25
  5. sentience/agent.py +804 -310
  6. sentience/agent_config.py +3 -0
  7. sentience/async_api.py +101 -0
  8. sentience/base_agent.py +95 -0
  9. sentience/browser.py +594 -25
  10. sentience/browser_evaluator.py +299 -0
  11. sentience/cloud_tracing.py +458 -36
  12. sentience/conversational_agent.py +79 -45
  13. sentience/element_filter.py +136 -0
  14. sentience/expect.py +98 -2
  15. sentience/extension/background.js +56 -185
  16. sentience/extension/content.js +117 -289
  17. sentience/extension/injected_api.js +799 -1374
  18. sentience/extension/manifest.json +1 -1
  19. sentience/extension/pkg/sentience_core.js +190 -396
  20. sentience/extension/pkg/sentience_core_bg.wasm +0 -0
  21. sentience/extension/release.json +47 -47
  22. sentience/formatting.py +9 -53
  23. sentience/inspector.py +183 -1
  24. sentience/llm_interaction_handler.py +191 -0
  25. sentience/llm_provider.py +256 -28
  26. sentience/llm_provider_utils.py +120 -0
  27. sentience/llm_response_builder.py +153 -0
  28. sentience/models.py +66 -1
  29. sentience/overlay.py +109 -2
  30. sentience/protocols.py +228 -0
  31. sentience/query.py +1 -1
  32. sentience/read.py +95 -3
  33. sentience/recorder.py +223 -3
  34. sentience/schemas/trace_v1.json +102 -9
  35. sentience/screenshot.py +48 -2
  36. sentience/sentience_methods.py +86 -0
  37. sentience/snapshot.py +309 -64
  38. sentience/snapshot_diff.py +141 -0
  39. sentience/text_search.py +119 -5
  40. sentience/trace_event_builder.py +129 -0
  41. sentience/trace_file_manager.py +197 -0
  42. sentience/trace_indexing/index_schema.py +95 -7
  43. sentience/trace_indexing/indexer.py +117 -14
  44. sentience/tracer_factory.py +119 -6
  45. sentience/tracing.py +172 -8
  46. sentience/utils/__init__.py +40 -0
  47. sentience/utils/browser.py +46 -0
  48. sentience/utils/element.py +257 -0
  49. sentience/utils/formatting.py +59 -0
  50. sentience/utils.py +1 -1
  51. sentience/visual_agent.py +2056 -0
  52. sentience/wait.py +70 -4
  53. {sentienceapi-0.90.12.dist-info → sentienceapi-0.92.2.dist-info}/METADATA +61 -22
  54. sentienceapi-0.92.2.dist-info/RECORD +65 -0
  55. sentienceapi-0.92.2.dist-info/licenses/LICENSE +24 -0
  56. sentienceapi-0.92.2.dist-info/licenses/LICENSE-APACHE +201 -0
  57. sentienceapi-0.92.2.dist-info/licenses/LICENSE-MIT +21 -0
  58. sentience/extension/test-content.js +0 -4
  59. sentienceapi-0.90.12.dist-info/RECORD +0 -46
  60. sentienceapi-0.90.12.dist-info/licenses/LICENSE.md +0 -43
  61. {sentienceapi-0.90.12.dist-info → sentienceapi-0.92.2.dist-info}/WHEEL +0 -0
  62. {sentienceapi-0.90.12.dist-info → sentienceapi-0.92.2.dist-info}/entry_points.txt +0 -0
  63. {sentienceapi-0.90.12.dist-info → sentienceapi-0.92.2.dist-info}/top_level.txt +0 -0
sentience/browser.py CHANGED
@@ -2,20 +2,27 @@
2
2
  Playwright browser harness with extension loading
3
3
  """
4
4
 
5
+ import asyncio
5
6
  import os
6
7
  import shutil
7
8
  import tempfile
8
9
  import time
9
10
  from pathlib import Path
11
+ from typing import Optional, Union
10
12
  from urllib.parse import urlparse
11
13
 
14
+ from playwright.async_api import BrowserContext as AsyncBrowserContext
15
+ from playwright.async_api import Page as AsyncPage
16
+ from playwright.async_api import Playwright as AsyncPlaywright
17
+ from playwright.async_api import async_playwright
12
18
  from playwright.sync_api import BrowserContext, Page, Playwright, sync_playwright
13
19
 
14
- from sentience.models import ProxyConfig, StorageState
20
+ from sentience._extension_loader import find_extension_path
21
+ from sentience.models import ProxyConfig, StorageState, Viewport
15
22
 
16
23
  # Import stealth for bot evasion (optional - graceful fallback if not available)
17
24
  try:
18
- from playwright_stealth import stealth_sync
25
+ from playwright_stealth import stealth_async, stealth_sync
19
26
 
20
27
  STEALTH_AVAILABLE = True
21
28
  except ImportError:
@@ -35,6 +42,8 @@ class SentienceBrowser:
35
42
  storage_state: str | Path | StorageState | dict | None = None,
36
43
  record_video_dir: str | Path | None = None,
37
44
  record_video_size: dict[str, int] | None = None,
45
+ viewport: Viewport | dict[str, int] | None = None,
46
+ device_scale_factor: float | None = None,
38
47
  ):
39
48
  """
40
49
  Initialize Sentience browser
@@ -67,6 +76,11 @@ class SentienceBrowser:
67
76
  Examples: {"width": 1280, "height": 800} (default)
68
77
  {"width": 1920, "height": 1080} (1080p)
69
78
  If None, defaults to 1280x800.
79
+ viewport: Optional viewport size as Viewport object or dict with 'width' and 'height' keys.
80
+ Examples: Viewport(width=1280, height=800) (default)
81
+ Viewport(width=1920, height=1080) (Full HD)
82
+ {"width": 1280, "height": 800} (dict also supported)
83
+ If None, defaults to Viewport(width=1280, height=800).
70
84
  """
71
85
  self.api_key = api_key
72
86
  # Only set api_url if api_key is provided, otherwise None (free tier)
@@ -94,6 +108,17 @@ class SentienceBrowser:
94
108
  self.record_video_dir = record_video_dir
95
109
  self.record_video_size = record_video_size or {"width": 1280, "height": 800}
96
110
 
111
+ # Viewport configuration - convert dict to Viewport if needed
112
+ if viewport is None:
113
+ self.viewport = Viewport(width=1280, height=800)
114
+ elif isinstance(viewport, dict):
115
+ self.viewport = Viewport(width=viewport["width"], height=viewport["height"])
116
+ else:
117
+ self.viewport = viewport
118
+
119
+ # Device scale factor for high-DPI emulation
120
+ self.device_scale_factor = device_scale_factor
121
+
97
122
  self.playwright: Playwright | None = None
98
123
  self.context: BrowserContext | None = None
99
124
  self.page: Page | None = None
@@ -147,28 +172,8 @@ class SentienceBrowser:
147
172
 
148
173
  def start(self) -> None:
149
174
  """Launch browser with extension loaded"""
150
- # Get extension source path (relative to project root/package)
151
- # Handle both development (src/) and installed package cases
152
-
153
- # 1. Try relative to this file (installed package structure)
154
- # sentience/browser.py -> sentience/extension/
155
- package_ext_path = Path(__file__).parent / "extension"
156
-
157
- # 2. Try development root (if running from source repo)
158
- # sentience/browser.py -> ../sentience-chrome
159
- dev_ext_path = Path(__file__).parent.parent.parent / "sentience-chrome"
160
-
161
- if package_ext_path.exists() and (package_ext_path / "manifest.json").exists():
162
- extension_source = package_ext_path
163
- elif dev_ext_path.exists() and (dev_ext_path / "manifest.json").exists():
164
- extension_source = dev_ext_path
165
- else:
166
- raise FileNotFoundError(
167
- f"Extension not found. Checked:\n"
168
- f"1. {package_ext_path}\n"
169
- f"2. {dev_ext_path}\n"
170
- "Make sure the extension is built and 'sentience/extension' directory exists."
171
- )
175
+ # Get extension source path using shared utility
176
+ extension_source = find_extension_path()
172
177
 
173
178
  # Create temporary extension bundle
174
179
  # We copy it to a temp dir to avoid file locking issues and ensure clean state
@@ -211,11 +216,15 @@ class SentienceBrowser:
211
216
  "user_data_dir": user_data_dir,
212
217
  "headless": False, # IMPORTANT: See note above
213
218
  "args": args,
214
- "viewport": {"width": 1280, "height": 800},
219
+ "viewport": {"width": self.viewport.width, "height": self.viewport.height},
215
220
  # Remove "HeadlessChrome" from User Agent automatically
216
221
  "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
217
222
  }
218
223
 
224
+ # Add device scale factor if configured
225
+ if self.device_scale_factor is not None:
226
+ launch_params["device_scale_factor"] = self.device_scale_factor
227
+
219
228
  # Add proxy if configured
220
229
  if proxy_config:
221
230
  launch_params["proxy"] = proxy_config.to_playwright_dict()
@@ -480,6 +489,97 @@ class SentienceBrowser:
480
489
 
481
490
  return final_path
482
491
 
492
+ @classmethod
493
+ def from_existing(
494
+ cls,
495
+ context: BrowserContext,
496
+ api_key: str | None = None,
497
+ api_url: str | None = None,
498
+ ) -> "SentienceBrowser":
499
+ """
500
+ Create SentienceBrowser from an existing Playwright BrowserContext.
501
+
502
+ This allows you to use Sentience SDK with a browser context you've already created,
503
+ giving you more control over browser initialization.
504
+
505
+ Args:
506
+ context: Existing Playwright BrowserContext
507
+ api_key: Optional API key for server-side processing
508
+ api_url: Optional API URL (defaults to https://api.sentienceapi.com if api_key provided)
509
+
510
+ Returns:
511
+ SentienceBrowser instance configured to use the existing context
512
+
513
+ Example:
514
+ from playwright.sync_api import sync_playwright
515
+ from sentience import SentienceBrowser, snapshot
516
+
517
+ with sync_playwright() as p:
518
+ context = p.chromium.launch_persistent_context(...)
519
+ browser = SentienceBrowser.from_existing(context)
520
+ browser.page.goto("https://example.com")
521
+ snap = snapshot(browser)
522
+ """
523
+ instance = cls(api_key=api_key, api_url=api_url)
524
+ instance.context = context
525
+ instance.page = context.pages[0] if context.pages else context.new_page()
526
+
527
+ # Apply stealth if available
528
+ if STEALTH_AVAILABLE:
529
+ stealth_sync(instance.page)
530
+
531
+ # Wait for extension to be ready (if extension is loaded)
532
+ time.sleep(0.5)
533
+
534
+ return instance
535
+
536
+ @classmethod
537
+ def from_page(
538
+ cls,
539
+ page: Page,
540
+ api_key: str | None = None,
541
+ api_url: str | None = None,
542
+ ) -> "SentienceBrowser":
543
+ """
544
+ Create SentienceBrowser from an existing Playwright Page.
545
+
546
+ This allows you to use Sentience SDK with a page you've already created,
547
+ giving you more control over browser initialization.
548
+
549
+ Args:
550
+ page: Existing Playwright Page
551
+ api_key: Optional API key for server-side processing
552
+ api_url: Optional API URL (defaults to https://api.sentienceapi.com if api_key provided)
553
+
554
+ Returns:
555
+ SentienceBrowser instance configured to use the existing page
556
+
557
+ Example:
558
+ from playwright.sync_api import sync_playwright
559
+ from sentience import SentienceBrowser, snapshot
560
+
561
+ with sync_playwright() as p:
562
+ browser_instance = p.chromium.launch()
563
+ context = browser_instance.new_context()
564
+ page = context.new_page()
565
+ page.goto("https://example.com")
566
+
567
+ browser = SentienceBrowser.from_page(page)
568
+ snap = snapshot(browser)
569
+ """
570
+ instance = cls(api_key=api_key, api_url=api_url)
571
+ instance.page = page
572
+ instance.context = page.context
573
+
574
+ # Apply stealth if available
575
+ if STEALTH_AVAILABLE:
576
+ stealth_sync(instance.page)
577
+
578
+ # Wait for extension to be ready (if extension is loaded)
579
+ time.sleep(0.5)
580
+
581
+ return instance
582
+
483
583
  def __enter__(self):
484
584
  """Context manager entry"""
485
585
  self.start()
@@ -488,3 +588,472 @@ class SentienceBrowser:
488
588
  def __exit__(self, exc_type, exc_val, exc_tb):
489
589
  """Context manager exit"""
490
590
  self.close()
591
+
592
+
593
+ class AsyncSentienceBrowser:
594
+ """Async version of SentienceBrowser for use in asyncio contexts."""
595
+
596
+ def __init__(
597
+ self,
598
+ api_key: str | None = None,
599
+ api_url: str | None = None,
600
+ headless: bool | None = None,
601
+ proxy: str | None = None,
602
+ user_data_dir: str | Path | None = None,
603
+ storage_state: str | Path | StorageState | dict | None = None,
604
+ record_video_dir: str | Path | None = None,
605
+ record_video_size: dict[str, int] | None = None,
606
+ viewport: Viewport | dict[str, int] | None = None,
607
+ device_scale_factor: float | None = None,
608
+ ):
609
+ """
610
+ Initialize Async Sentience browser
611
+
612
+ Args:
613
+ api_key: Optional API key for server-side processing (Pro/Enterprise tiers)
614
+ If None, uses free tier (local extension only)
615
+ api_url: Server URL for API calls (defaults to https://api.sentienceapi.com if api_key provided)
616
+ headless: Whether to run in headless mode. If None, defaults to True in CI, False otherwise
617
+ proxy: Optional proxy server URL (e.g., 'http://user:pass@proxy.example.com:8080')
618
+ user_data_dir: Optional path to user data directory for persistent sessions
619
+ storage_state: Optional storage state to inject (cookies + localStorage)
620
+ record_video_dir: Optional directory path to save video recordings
621
+ record_video_size: Optional video resolution as dict with 'width' and 'height' keys
622
+ viewport: Optional viewport size as Viewport object or dict with 'width' and 'height' keys.
623
+ Examples: Viewport(width=1280, height=800) (default)
624
+ Viewport(width=1920, height=1080) (Full HD)
625
+ {"width": 1280, "height": 800} (dict also supported)
626
+ If None, defaults to Viewport(width=1280, height=800).
627
+ device_scale_factor: Optional device scale factor to emulate high-DPI (Retina) screens.
628
+ Examples: 1.0 (default, standard DPI)
629
+ 2.0 (Retina/high-DPI, like MacBook Pro)
630
+ 3.0 (very high DPI)
631
+ If None, defaults to 1.0 (standard DPI).
632
+ """
633
+ self.api_key = api_key
634
+ # Only set api_url if api_key is provided, otherwise None (free tier)
635
+ if self.api_key and not api_url:
636
+ self.api_url = "https://api.sentienceapi.com"
637
+ else:
638
+ self.api_url = api_url
639
+
640
+ # Determine headless mode
641
+ if headless is None:
642
+ # Default to False for local dev, True for CI
643
+ self.headless = os.environ.get("CI", "").lower() == "true"
644
+ else:
645
+ self.headless = headless
646
+
647
+ # Support proxy from argument or environment variable
648
+ self.proxy = proxy or os.environ.get("SENTIENCE_PROXY")
649
+
650
+ # Auth injection support
651
+ self.user_data_dir = user_data_dir
652
+ self.storage_state = storage_state
653
+
654
+ # Video recording support
655
+ self.record_video_dir = record_video_dir
656
+ self.record_video_size = record_video_size or {"width": 1280, "height": 800}
657
+
658
+ # Viewport configuration - convert dict to Viewport if needed
659
+ if viewport is None:
660
+ self.viewport = Viewport(width=1280, height=800)
661
+ elif isinstance(viewport, dict):
662
+ self.viewport = Viewport(width=viewport["width"], height=viewport["height"])
663
+ else:
664
+ self.viewport = viewport
665
+
666
+ # Device scale factor for high-DPI emulation
667
+ self.device_scale_factor = device_scale_factor
668
+
669
+ self.playwright: AsyncPlaywright | None = None
670
+ self.context: AsyncBrowserContext | None = None
671
+ self.page: AsyncPage | None = None
672
+ self._extension_path: str | None = None
673
+
674
+ def _parse_proxy(self, proxy_string: str) -> ProxyConfig | None:
675
+ """
676
+ Parse proxy connection string into ProxyConfig.
677
+
678
+ Args:
679
+ proxy_string: Proxy URL (e.g., 'http://user:pass@proxy.example.com:8080')
680
+
681
+ Returns:
682
+ ProxyConfig object or None if invalid
683
+ """
684
+ if not proxy_string:
685
+ return None
686
+
687
+ try:
688
+ parsed = urlparse(proxy_string)
689
+
690
+ # Validate scheme
691
+ if parsed.scheme not in ("http", "https", "socks5"):
692
+ print(f"⚠️ [Sentience] Unsupported proxy scheme: {parsed.scheme}")
693
+ print(" Supported: http, https, socks5")
694
+ return None
695
+
696
+ # Validate host and port
697
+ if not parsed.hostname or not parsed.port:
698
+ print("⚠️ [Sentience] Proxy URL must include hostname and port")
699
+ print(" Expected format: http://username:password@host:port")
700
+ return None
701
+
702
+ # Build server URL
703
+ server = f"{parsed.scheme}://{parsed.hostname}:{parsed.port}"
704
+
705
+ # Create ProxyConfig with optional credentials
706
+ return ProxyConfig(
707
+ server=server,
708
+ username=parsed.username if parsed.username else None,
709
+ password=parsed.password if parsed.password else None,
710
+ )
711
+
712
+ except Exception as e:
713
+ print(f"⚠️ [Sentience] Invalid proxy configuration: {e}")
714
+ print(" Expected format: http://username:password@host:port")
715
+ return None
716
+
717
+ async def start(self) -> None:
718
+ """Launch browser with extension loaded (async)"""
719
+ # Get extension source path using shared utility
720
+ extension_source = find_extension_path()
721
+
722
+ # Create temporary extension bundle
723
+ self._extension_path = tempfile.mkdtemp(prefix="sentience-ext-")
724
+ shutil.copytree(extension_source, self._extension_path, dirs_exist_ok=True)
725
+
726
+ self.playwright = await async_playwright().start()
727
+
728
+ # Build launch arguments
729
+ args = [
730
+ f"--disable-extensions-except={self._extension_path}",
731
+ f"--load-extension={self._extension_path}",
732
+ "--disable-blink-features=AutomationControlled",
733
+ "--no-sandbox",
734
+ "--disable-infobars",
735
+ "--disable-features=WebRtcHideLocalIpsWithMdns",
736
+ "--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
737
+ ]
738
+
739
+ if self.headless:
740
+ args.append("--headless=new")
741
+
742
+ # Parse proxy configuration if provided
743
+ proxy_config = self._parse_proxy(self.proxy) if self.proxy else None
744
+
745
+ # Handle User Data Directory
746
+ if self.user_data_dir:
747
+ user_data_dir = str(self.user_data_dir)
748
+ Path(user_data_dir).mkdir(parents=True, exist_ok=True)
749
+ else:
750
+ user_data_dir = ""
751
+
752
+ # Build launch_persistent_context parameters
753
+ launch_params = {
754
+ "user_data_dir": user_data_dir,
755
+ "headless": False,
756
+ "args": args,
757
+ "viewport": {"width": self.viewport.width, "height": self.viewport.height},
758
+ "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
759
+ }
760
+
761
+ # Add device scale factor if configured
762
+ if self.device_scale_factor is not None:
763
+ launch_params["device_scale_factor"] = self.device_scale_factor
764
+
765
+ # Add proxy if configured
766
+ if proxy_config:
767
+ launch_params["proxy"] = proxy_config.to_playwright_dict()
768
+ launch_params["ignore_https_errors"] = True
769
+ print(f"🌐 [Sentience] Using proxy: {proxy_config.server}")
770
+
771
+ # Add video recording if configured
772
+ if self.record_video_dir:
773
+ video_dir = Path(self.record_video_dir)
774
+ video_dir.mkdir(parents=True, exist_ok=True)
775
+ launch_params["record_video_dir"] = str(video_dir)
776
+ launch_params["record_video_size"] = self.record_video_size
777
+ print(f"🎥 [Sentience] Recording video to: {video_dir}")
778
+ print(
779
+ f" Resolution: {self.record_video_size['width']}x{self.record_video_size['height']}"
780
+ )
781
+
782
+ # Launch persistent context
783
+ self.context = await self.playwright.chromium.launch_persistent_context(**launch_params)
784
+
785
+ self.page = self.context.pages[0] if self.context.pages else await self.context.new_page()
786
+
787
+ # Inject storage state if provided
788
+ if self.storage_state:
789
+ await self._inject_storage_state(self.storage_state)
790
+
791
+ # Apply stealth if available
792
+ if STEALTH_AVAILABLE:
793
+ await stealth_async(self.page)
794
+
795
+ # Wait a moment for extension to initialize
796
+ await asyncio.sleep(0.5)
797
+
798
+ async def goto(self, url: str) -> None:
799
+ """Navigate to a URL and ensure extension is ready (async)"""
800
+ if not self.page:
801
+ raise RuntimeError("Browser not started. Call await start() first.")
802
+
803
+ await self.page.goto(url, wait_until="domcontentloaded")
804
+
805
+ # Wait for extension to be ready
806
+ if not await self._wait_for_extension():
807
+ try:
808
+ diag = await self.page.evaluate(
809
+ """() => ({
810
+ sentience_defined: typeof window.sentience !== 'undefined',
811
+ registry_defined: typeof window.sentience_registry !== 'undefined',
812
+ snapshot_defined: window.sentience && typeof window.sentience.snapshot === 'function',
813
+ extension_id: document.documentElement.dataset.sentienceExtensionId || 'not set',
814
+ url: window.location.href
815
+ })"""
816
+ )
817
+ except Exception as e:
818
+ diag = f"Failed to get diagnostics: {str(e)}"
819
+
820
+ raise RuntimeError(
821
+ "Extension failed to load after navigation. Make sure:\n"
822
+ "1. Extension is built (cd sentience-chrome && ./build.sh)\n"
823
+ "2. All files are present (manifest.json, content.js, injected_api.js, pkg/)\n"
824
+ "3. Check browser console for errors (run with headless=False to see console)\n"
825
+ f"4. Extension path: {self._extension_path}\n"
826
+ f"5. Diagnostic info: {diag}"
827
+ )
828
+
829
+ async def _inject_storage_state(self, storage_state: str | Path | StorageState | dict) -> None:
830
+ """Inject storage state (cookies + localStorage) into browser context (async)"""
831
+ import json
832
+
833
+ # Load storage state
834
+ if isinstance(storage_state, (str, Path)):
835
+ with open(storage_state, encoding="utf-8") as f:
836
+ state_dict = json.load(f)
837
+ state = StorageState.from_dict(state_dict)
838
+ elif isinstance(storage_state, StorageState):
839
+ state = storage_state
840
+ elif isinstance(storage_state, dict):
841
+ state = StorageState.from_dict(storage_state)
842
+ else:
843
+ raise ValueError(
844
+ f"Invalid storage_state type: {type(storage_state)}. "
845
+ "Expected str, Path, StorageState, or dict."
846
+ )
847
+
848
+ # Inject cookies
849
+ if state.cookies:
850
+ playwright_cookies = []
851
+ for cookie in state.cookies:
852
+ cookie_dict = cookie.model_dump()
853
+ playwright_cookie = {
854
+ "name": cookie_dict["name"],
855
+ "value": cookie_dict["value"],
856
+ "domain": cookie_dict["domain"],
857
+ "path": cookie_dict["path"],
858
+ }
859
+ if cookie_dict.get("expires"):
860
+ playwright_cookie["expires"] = cookie_dict["expires"]
861
+ if cookie_dict.get("httpOnly"):
862
+ playwright_cookie["httpOnly"] = cookie_dict["httpOnly"]
863
+ if cookie_dict.get("secure"):
864
+ playwright_cookie["secure"] = cookie_dict["secure"]
865
+ if cookie_dict.get("sameSite"):
866
+ playwright_cookie["sameSite"] = cookie_dict["sameSite"]
867
+ playwright_cookies.append(playwright_cookie)
868
+
869
+ await self.context.add_cookies(playwright_cookies)
870
+ print(f"✅ [Sentience] Injected {len(state.cookies)} cookie(s)")
871
+
872
+ # Inject LocalStorage
873
+ if state.origins:
874
+ for origin_data in state.origins:
875
+ origin = origin_data.origin
876
+ if not origin:
877
+ continue
878
+
879
+ try:
880
+ await self.page.goto(origin, wait_until="domcontentloaded", timeout=10000)
881
+
882
+ if origin_data.localStorage:
883
+ localStorage_dict = {
884
+ item.name: item.value for item in origin_data.localStorage
885
+ }
886
+ await self.page.evaluate(
887
+ """(localStorage_data) => {
888
+ for (const [key, value] of Object.entries(localStorage_data)) {
889
+ localStorage.setItem(key, value);
890
+ }
891
+ }""",
892
+ localStorage_dict,
893
+ )
894
+ print(
895
+ f"✅ [Sentience] Injected {len(origin_data.localStorage)} localStorage item(s) for {origin}"
896
+ )
897
+ except Exception as e:
898
+ print(f"⚠️ [Sentience] Failed to inject localStorage for {origin}: {e}")
899
+
900
+ async def _wait_for_extension(self, timeout_sec: float = 5.0) -> bool:
901
+ """Poll for window.sentience to be available (async)"""
902
+ start_time = time.time()
903
+ last_error = None
904
+
905
+ while time.time() - start_time < timeout_sec:
906
+ try:
907
+ result = await self.page.evaluate(
908
+ """() => {
909
+ if (typeof window.sentience === 'undefined') {
910
+ return { ready: false, reason: 'window.sentience undefined' };
911
+ }
912
+ if (window.sentience._wasmModule === null) {
913
+ return { ready: false, reason: 'WASM module not fully loaded' };
914
+ }
915
+ return { ready: true };
916
+ }
917
+ """
918
+ )
919
+
920
+ if isinstance(result, dict):
921
+ if result.get("ready"):
922
+ return True
923
+ last_error = result.get("reason", "Unknown error")
924
+ except Exception as e:
925
+ last_error = f"Evaluation error: {str(e)}"
926
+
927
+ await asyncio.sleep(0.3)
928
+
929
+ if last_error:
930
+ import warnings
931
+
932
+ warnings.warn(f"Extension wait timeout. Last status: {last_error}")
933
+
934
+ return False
935
+
936
+ async def close(self, output_path: str | Path | None = None) -> str | None:
937
+ """
938
+ Close browser and cleanup (async)
939
+
940
+ Args:
941
+ output_path: Optional path to rename the video file to
942
+
943
+ Returns:
944
+ Path to video file if recording was enabled, None otherwise
945
+ """
946
+ temp_video_path = None
947
+
948
+ if self.record_video_dir:
949
+ try:
950
+ if self.page and self.page.video:
951
+ temp_video_path = await self.page.video.path()
952
+ elif self.context:
953
+ for page in self.context.pages:
954
+ if page.video:
955
+ temp_video_path = await page.video.path()
956
+ break
957
+ except Exception:
958
+ pass
959
+
960
+ if self.context:
961
+ await self.context.close()
962
+ self.context = None
963
+
964
+ if self.playwright:
965
+ await self.playwright.stop()
966
+ self.playwright = None
967
+
968
+ if self._extension_path and os.path.exists(self._extension_path):
969
+ shutil.rmtree(self._extension_path)
970
+
971
+ # Clear page reference after closing context
972
+ self.page = None
973
+
974
+ final_path = temp_video_path
975
+ if temp_video_path and output_path and os.path.exists(temp_video_path):
976
+ try:
977
+ output_path = str(output_path)
978
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
979
+ shutil.move(temp_video_path, output_path)
980
+ final_path = output_path
981
+ except Exception as e:
982
+ import warnings
983
+
984
+ warnings.warn(f"Failed to rename video file: {e}")
985
+ final_path = temp_video_path
986
+
987
+ return final_path
988
+
989
+ async def __aenter__(self):
990
+ """Async context manager entry"""
991
+ await self.start()
992
+ return self
993
+
994
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
995
+ """Async context manager exit"""
996
+ await self.close()
997
+
998
+ @classmethod
999
+ async def from_existing(
1000
+ cls,
1001
+ context: AsyncBrowserContext,
1002
+ api_key: str | None = None,
1003
+ api_url: str | None = None,
1004
+ ) -> "AsyncSentienceBrowser":
1005
+ """
1006
+ Create AsyncSentienceBrowser from an existing Playwright BrowserContext.
1007
+
1008
+ Args:
1009
+ context: Existing Playwright BrowserContext
1010
+ api_key: Optional API key for server-side processing
1011
+ api_url: Optional API URL
1012
+
1013
+ Returns:
1014
+ AsyncSentienceBrowser instance configured to use the existing context
1015
+ """
1016
+ instance = cls(api_key=api_key, api_url=api_url)
1017
+ instance.context = context
1018
+ pages = context.pages
1019
+ instance.page = pages[0] if pages else await context.new_page()
1020
+
1021
+ # Apply stealth if available
1022
+ if STEALTH_AVAILABLE:
1023
+ await stealth_async(instance.page)
1024
+
1025
+ # Wait for extension to be ready
1026
+ await asyncio.sleep(0.5)
1027
+
1028
+ return instance
1029
+
1030
+ @classmethod
1031
+ async def from_page(
1032
+ cls,
1033
+ page: AsyncPage,
1034
+ api_key: str | None = None,
1035
+ api_url: str | None = None,
1036
+ ) -> "AsyncSentienceBrowser":
1037
+ """
1038
+ Create AsyncSentienceBrowser from an existing Playwright Page.
1039
+
1040
+ Args:
1041
+ page: Existing Playwright Page
1042
+ api_key: Optional API key for server-side processing
1043
+ api_url: Optional API URL
1044
+
1045
+ Returns:
1046
+ AsyncSentienceBrowser instance configured to use the existing page
1047
+ """
1048
+ instance = cls(api_key=api_key, api_url=api_url)
1049
+ instance.page = page
1050
+ instance.context = page.context
1051
+
1052
+ # Apply stealth if available
1053
+ if STEALTH_AVAILABLE:
1054
+ await stealth_async(instance.page)
1055
+
1056
+ # Wait for extension to be ready
1057
+ await asyncio.sleep(0.5)
1058
+
1059
+ return instance