sentienceapi 0.90.11__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.
Files changed (46) hide show
  1. sentience/__init__.py +153 -0
  2. sentience/actions.py +439 -0
  3. sentience/agent.py +687 -0
  4. sentience/agent_config.py +43 -0
  5. sentience/base_agent.py +101 -0
  6. sentience/browser.py +409 -0
  7. sentience/cli.py +130 -0
  8. sentience/cloud_tracing.py +292 -0
  9. sentience/conversational_agent.py +509 -0
  10. sentience/expect.py +92 -0
  11. sentience/extension/background.js +233 -0
  12. sentience/extension/content.js +298 -0
  13. sentience/extension/injected_api.js +1473 -0
  14. sentience/extension/manifest.json +36 -0
  15. sentience/extension/pkg/sentience_core.d.ts +51 -0
  16. sentience/extension/pkg/sentience_core.js +529 -0
  17. sentience/extension/pkg/sentience_core_bg.wasm +0 -0
  18. sentience/extension/pkg/sentience_core_bg.wasm.d.ts +10 -0
  19. sentience/extension/release.json +115 -0
  20. sentience/extension/test-content.js +4 -0
  21. sentience/formatting.py +59 -0
  22. sentience/generator.py +202 -0
  23. sentience/inspector.py +185 -0
  24. sentience/llm_provider.py +431 -0
  25. sentience/models.py +406 -0
  26. sentience/overlay.py +115 -0
  27. sentience/query.py +303 -0
  28. sentience/read.py +96 -0
  29. sentience/recorder.py +369 -0
  30. sentience/schemas/trace_v1.json +216 -0
  31. sentience/screenshot.py +54 -0
  32. sentience/snapshot.py +282 -0
  33. sentience/text_search.py +150 -0
  34. sentience/trace_indexing/__init__.py +27 -0
  35. sentience/trace_indexing/index_schema.py +111 -0
  36. sentience/trace_indexing/indexer.py +363 -0
  37. sentience/tracer_factory.py +211 -0
  38. sentience/tracing.py +285 -0
  39. sentience/utils.py +296 -0
  40. sentience/wait.py +73 -0
  41. sentienceapi-0.90.11.dist-info/METADATA +878 -0
  42. sentienceapi-0.90.11.dist-info/RECORD +46 -0
  43. sentienceapi-0.90.11.dist-info/WHEEL +5 -0
  44. sentienceapi-0.90.11.dist-info/entry_points.txt +2 -0
  45. sentienceapi-0.90.11.dist-info/licenses/LICENSE.md +43 -0
  46. sentienceapi-0.90.11.dist-info/top_level.txt +1 -0
sentience/recorder.py ADDED
@@ -0,0 +1,369 @@
1
+ """
2
+ Recorder - captures user actions into a trace
3
+ """
4
+
5
+ import json
6
+ from datetime import datetime
7
+ from typing import Any
8
+
9
+ from .browser import SentienceBrowser
10
+ from .models import Element, Snapshot
11
+ from .snapshot import snapshot
12
+
13
+
14
+ class TraceStep:
15
+ """A single step in a trace"""
16
+
17
+ def __init__(
18
+ self,
19
+ ts: int,
20
+ type: str,
21
+ selector: str | None = None,
22
+ element_id: int | None = None,
23
+ text: str | None = None,
24
+ key: str | None = None,
25
+ url: str | None = None,
26
+ snapshot: Snapshot | None = None,
27
+ ):
28
+ self.ts = ts
29
+ self.type = type
30
+ self.selector = selector
31
+ self.element_id = element_id
32
+ self.text = text
33
+ self.key = key
34
+ self.url = url
35
+ self.snapshot = snapshot
36
+
37
+ def to_dict(self) -> dict[str, Any]:
38
+ """Convert to dictionary for JSON serialization"""
39
+ result = {
40
+ "ts": self.ts,
41
+ "type": self.type,
42
+ }
43
+ if self.selector is not None:
44
+ result["selector"] = self.selector
45
+ if self.element_id is not None:
46
+ result["element_id"] = self.element_id
47
+ if self.text is not None:
48
+ result["text"] = self.text
49
+ if self.key is not None:
50
+ result["key"] = self.key
51
+ if self.url is not None:
52
+ result["url"] = self.url
53
+ if self.snapshot is not None:
54
+ result["snapshot"] = self.snapshot.model_dump()
55
+ return result
56
+
57
+
58
+ class Trace:
59
+ """Trace of user actions"""
60
+
61
+ def __init__(self, start_url: str):
62
+ self.version = "1.0.0"
63
+ self.created_at = datetime.now().isoformat()
64
+ self.start_url = start_url
65
+ self.steps: list[TraceStep] = []
66
+ self._start_time = datetime.now()
67
+
68
+ def add_step(self, step: TraceStep) -> None:
69
+ """Add a step to the trace"""
70
+ self.steps.append(step)
71
+
72
+ def add_navigation(self, url: str) -> None:
73
+ """Add navigation step"""
74
+ ts = int((datetime.now() - self._start_time).total_seconds() * 1000)
75
+ step = TraceStep(ts=ts, type="navigation", url=url)
76
+ self.add_step(step)
77
+
78
+ def add_click(self, element_id: int, selector: str | None = None) -> None:
79
+ """Add click step"""
80
+ ts = int((datetime.now() - self._start_time).total_seconds() * 1000)
81
+ step = TraceStep(ts=ts, type="click", element_id=element_id, selector=selector)
82
+ self.add_step(step)
83
+
84
+ def add_type(
85
+ self,
86
+ element_id: int,
87
+ text: str,
88
+ selector: str | None = None,
89
+ mask: bool = False,
90
+ ) -> None:
91
+ """Add type step"""
92
+ ts = int((datetime.now() - self._start_time).total_seconds() * 1000)
93
+ # Mask sensitive data if requested
94
+ masked_text = "***" if mask else text
95
+ step = TraceStep(
96
+ ts=ts,
97
+ type="type",
98
+ element_id=element_id,
99
+ text=masked_text,
100
+ selector=selector,
101
+ )
102
+ self.add_step(step)
103
+
104
+ def add_press(self, key: str) -> None:
105
+ """Add press key step"""
106
+ ts = int((datetime.now() - self._start_time).total_seconds() * 1000)
107
+ step = TraceStep(ts=ts, type="press", key=key)
108
+ self.add_step(step)
109
+
110
+ def save(self, filepath: str) -> None:
111
+ """Save trace to JSON file"""
112
+ data = {
113
+ "version": self.version,
114
+ "created_at": self.created_at,
115
+ "start_url": self.start_url,
116
+ "steps": [step.to_dict() for step in self.steps],
117
+ }
118
+ with open(filepath, "w") as f:
119
+ json.dump(data, f, indent=2)
120
+
121
+ @classmethod
122
+ def load(cls, filepath: str) -> "Trace":
123
+ """Load trace from JSON file"""
124
+ with open(filepath) as f:
125
+ data = json.load(f)
126
+
127
+ trace = cls(data["start_url"])
128
+ trace.version = data["version"]
129
+ trace.created_at = data["created_at"]
130
+
131
+ for step_data in data["steps"]:
132
+ snapshot_data = step_data.get("snapshot")
133
+ snapshot_obj = None
134
+ if snapshot_data:
135
+ snapshot_obj = Snapshot(**snapshot_data)
136
+
137
+ step = TraceStep(
138
+ ts=step_data["ts"],
139
+ type=step_data["type"],
140
+ selector=step_data.get("selector"),
141
+ element_id=step_data.get("element_id"),
142
+ text=step_data.get("text"),
143
+ key=step_data.get("key"),
144
+ url=step_data.get("url"),
145
+ snapshot=snapshot_obj,
146
+ )
147
+ trace.steps.append(step)
148
+
149
+ return trace
150
+
151
+
152
+ class Recorder:
153
+ """Recorder for capturing user actions"""
154
+
155
+ def __init__(self, browser: SentienceBrowser, capture_snapshots: bool = False):
156
+ self.browser = browser
157
+ self.capture_snapshots = capture_snapshots
158
+ self.trace: Trace | None = None
159
+ self._active = False
160
+ self._mask_patterns: list[str] = [] # Patterns to mask (e.g., "password", "email")
161
+
162
+ def start(self) -> None:
163
+ """Start recording"""
164
+ if not self.browser.page:
165
+ raise RuntimeError("Browser not started. Call browser.start() first.")
166
+
167
+ self._active = True
168
+ start_url = self.browser.page.url
169
+ self.trace = Trace(start_url)
170
+
171
+ # Set up event listeners in the browser
172
+ self._setup_listeners()
173
+
174
+ def stop(self) -> None:
175
+ """Stop recording"""
176
+ self._active = False
177
+ self._cleanup_listeners()
178
+
179
+ def add_mask_pattern(self, pattern: str) -> None:
180
+ """Add a pattern to mask in recorded text (e.g., "password", "email")"""
181
+ self._mask_patterns.append(pattern.lower())
182
+
183
+ def _should_mask(self, text: str) -> bool:
184
+ """Check if text should be masked"""
185
+ text_lower = text.lower()
186
+ return any(pattern in text_lower for pattern in self._mask_patterns)
187
+
188
+ def _setup_listeners(self) -> None:
189
+ """Set up event listeners to capture actions"""
190
+ # Note: We'll capture actions through the SDK methods rather than DOM events
191
+ # This is cleaner and more reliable
192
+ pass
193
+
194
+ def _cleanup_listeners(self) -> None:
195
+ """Clean up event listeners"""
196
+ pass
197
+
198
+ def _infer_selector(self, element_id: int) -> str | None: # noqa: C901
199
+ """
200
+ Infer a semantic selector for an element
201
+
202
+ Uses heuristics to build a robust selector:
203
+ - role=... text~"..."
204
+ - If text empty: use name/aria-label/placeholder
205
+ - Include clickable=true when relevant
206
+ - Validate against snapshot (should match 1 element)
207
+ """
208
+ try:
209
+ # Take a snapshot to get element info
210
+ snap = snapshot(self.browser)
211
+
212
+ # Find the element in the snapshot
213
+ element = None
214
+ for el in snap.elements:
215
+ if el.id == element_id:
216
+ element = el
217
+ break
218
+
219
+ if not element:
220
+ return None
221
+
222
+ # Build candidate selector
223
+ parts = []
224
+
225
+ # Add role
226
+ if element.role and element.role != "generic":
227
+ parts.append(f"role={element.role}")
228
+
229
+ # Add text if available
230
+ if element.text:
231
+ # Use contains match for text
232
+ text_part = element.text.replace('"', '\\"')[:50] # Limit length
233
+ parts.append(f'text~"{text_part}"')
234
+ else:
235
+ # Try to get name/aria-label/placeholder from DOM
236
+ try:
237
+ el = self.browser.page.evaluate(
238
+ f"""
239
+ () => {{
240
+ const el = window.sentience_registry[{element_id}];
241
+ if (!el) return null;
242
+ return {{
243
+ name: el.name || null,
244
+ ariaLabel: el.getAttribute('aria-label') || null,
245
+ placeholder: el.placeholder || null
246
+ }};
247
+ }}
248
+ """
249
+ )
250
+
251
+ if el:
252
+ if el.get("name"):
253
+ parts.append(f'name="{el["name"]}"')
254
+ elif el.get("ariaLabel"):
255
+ parts.append(f'text~"{el["ariaLabel"]}"')
256
+ elif el.get("placeholder"):
257
+ parts.append(f'text~"{el["placeholder"]}"')
258
+ except Exception:
259
+ pass
260
+
261
+ # Add clickable if relevant
262
+ if element.visual_cues.is_clickable:
263
+ parts.append("clickable=true")
264
+
265
+ if not parts:
266
+ return None
267
+
268
+ selector = " ".join(parts)
269
+
270
+ # Validate selector - should match exactly 1 element
271
+ matches = [el for el in snap.elements if self._match_element(el, selector)]
272
+
273
+ if len(matches) == 1:
274
+ return selector
275
+ elif len(matches) > 1:
276
+ # Add more constraints (importance threshold, near-center)
277
+ # For now, just return the selector with a note
278
+ return selector
279
+ else:
280
+ # Selector doesn't match - return None (will use element_id)
281
+ return None
282
+
283
+ except Exception:
284
+ return None
285
+
286
+ def _match_element(self, element: Element, selector: str) -> bool:
287
+ """Simple selector matching (basic implementation)"""
288
+ # This is a simplified version - in production, use the full query engine
289
+ from .query import match_element, parse_selector
290
+
291
+ try:
292
+ query_dict = parse_selector(selector)
293
+ return match_element(element, query_dict)
294
+ except Exception:
295
+ return False
296
+
297
+ def record_navigation(self, url: str) -> None:
298
+ """Record a navigation event"""
299
+ if self._active and self.trace:
300
+ self.trace.add_navigation(url)
301
+
302
+ def record_click(self, element_id: int, selector: str | None = None) -> None:
303
+ """Record a click event with smart selector inference"""
304
+ if self._active and self.trace:
305
+ # If no selector provided, try to infer one
306
+ if selector is None:
307
+ selector = self._infer_selector(element_id)
308
+
309
+ # Optionally capture snapshot
310
+ if self.capture_snapshots:
311
+ try:
312
+ snap = snapshot(self.browser)
313
+ step = TraceStep(
314
+ ts=int((datetime.now() - self.trace._start_time).total_seconds() * 1000),
315
+ type="click",
316
+ element_id=element_id,
317
+ selector=selector,
318
+ snapshot=snap,
319
+ )
320
+ self.trace.add_step(step)
321
+ except Exception:
322
+ # If snapshot fails, just record without it
323
+ self.trace.add_click(element_id, selector)
324
+ else:
325
+ self.trace.add_click(element_id, selector)
326
+
327
+ def record_type(self, element_id: int, text: str, selector: str | None = None) -> None:
328
+ """Record a type event with smart selector inference"""
329
+ if self._active and self.trace:
330
+ # If no selector provided, try to infer one
331
+ if selector is None:
332
+ selector = self._infer_selector(element_id)
333
+
334
+ mask = self._should_mask(text)
335
+ self.trace.add_type(element_id, text, selector, mask=mask)
336
+
337
+ def record_press(self, key: str) -> None:
338
+ """Record a key press event"""
339
+ if self._active and self.trace:
340
+ self.trace.add_press(key)
341
+
342
+ def save(self, filepath: str) -> None:
343
+ """Save trace to file"""
344
+ if not self.trace:
345
+ raise RuntimeError("No trace to save. Start recording first.")
346
+ self.trace.save(filepath)
347
+
348
+ def __enter__(self):
349
+ """Context manager entry"""
350
+ self.start()
351
+ return self
352
+
353
+ def __exit__(self, exc_type, exc_val, exc_tb):
354
+ """Context manager exit"""
355
+ self.stop()
356
+
357
+
358
+ def record(browser: SentienceBrowser, capture_snapshots: bool = False) -> Recorder:
359
+ """
360
+ Create a recorder instance
361
+
362
+ Args:
363
+ browser: SentienceBrowser instance
364
+ capture_snapshots: Whether to capture snapshots at each step
365
+
366
+ Returns:
367
+ Recorder instance
368
+ """
369
+ return Recorder(browser, capture_snapshots=capture_snapshots)
@@ -0,0 +1,216 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://sentience.ai/schemas/trace/v1",
4
+ "title": "Sentience Agent Trace Event",
5
+ "description": "Schema for Sentience agent trace events in JSONL format",
6
+ "type": "object",
7
+ "required": ["v", "type", "ts", "run_id", "seq", "data"],
8
+ "properties": {
9
+ "v": {
10
+ "type": "integer",
11
+ "const": 1,
12
+ "description": "Schema version"
13
+ },
14
+ "type": {
15
+ "type": "string",
16
+ "enum": ["run_start", "step_start", "snapshot_taken", "llm_called", "action_executed", "verification", "recovery", "step_end", "run_end", "error"],
17
+ "description": "Event type"
18
+ },
19
+ "ts": {
20
+ "type": "string",
21
+ "format": "date-time",
22
+ "description": "ISO 8601 timestamp"
23
+ },
24
+ "ts_ms": {
25
+ "type": "integer",
26
+ "description": "Unix timestamp in milliseconds"
27
+ },
28
+ "run_id": {
29
+ "type": "string",
30
+ "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
31
+ "description": "UUID for the agent run"
32
+ },
33
+ "seq": {
34
+ "type": "integer",
35
+ "minimum": 1,
36
+ "description": "Monotonically increasing sequence number"
37
+ },
38
+ "step_id": {
39
+ "type": ["string", "null"],
40
+ "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
41
+ "description": "UUID for the step (present for step-scoped events)"
42
+ },
43
+ "data": {
44
+ "type": "object",
45
+ "description": "Event-specific payload",
46
+ "oneOf": [
47
+ {
48
+ "description": "run_start data",
49
+ "properties": {
50
+ "agent": {"type": "string"},
51
+ "llm_model": {"type": ["string", "null"]},
52
+ "config": {"type": "object"}
53
+ }
54
+ },
55
+ {
56
+ "description": "step_start data",
57
+ "required": ["step_id", "step_index", "goal", "attempt"],
58
+ "properties": {
59
+ "step_id": {"type": "string"},
60
+ "step_index": {"type": "integer"},
61
+ "goal": {"type": "string"},
62
+ "attempt": {"type": "integer"},
63
+ "pre_url": {"type": ["string", "null"]}
64
+ }
65
+ },
66
+ {
67
+ "description": "snapshot_taken data",
68
+ "required": ["step_id", "snapshot_digest"],
69
+ "properties": {
70
+ "step_id": {"type": "string"},
71
+ "snapshot_id": {"type": ["string", "null"]},
72
+ "snapshot_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
73
+ "snapshot_digest_loose": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
74
+ "url": {"type": ["string", "null"]},
75
+ "element_count": {"type": "integer"}
76
+ }
77
+ },
78
+ {
79
+ "description": "llm_called data",
80
+ "required": ["step_id", "response_text", "response_hash"],
81
+ "properties": {
82
+ "step_id": {"type": "string"},
83
+ "model": {"type": ["string", "null"]},
84
+ "temperature": {"type": "number"},
85
+ "system_prompt_hash": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
86
+ "user_prompt_hash": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
87
+ "response_text": {"type": "string"},
88
+ "response_hash": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
89
+ "usage": {
90
+ "type": "object",
91
+ "properties": {
92
+ "prompt_tokens": {"type": "integer"},
93
+ "completion_tokens": {"type": "integer"},
94
+ "total_tokens": {"type": "integer"}
95
+ }
96
+ }
97
+ }
98
+ },
99
+ {
100
+ "description": "step_end data (StepResult)",
101
+ "required": ["step_id", "step_index", "goal", "attempt", "pre", "llm", "exec", "post", "verify"],
102
+ "properties": {
103
+ "v": {"type": "integer", "const": 1},
104
+ "step_id": {"type": "string"},
105
+ "step_index": {"type": "integer"},
106
+ "goal": {"type": "string"},
107
+ "attempt": {"type": "integer"},
108
+ "pre": {
109
+ "type": "object",
110
+ "required": ["snapshot_digest"],
111
+ "properties": {
112
+ "url": {"type": ["string", "null"]},
113
+ "snapshot_digest": {"type": "string"},
114
+ "snapshot_digest_loose": {"type": "string"}
115
+ }
116
+ },
117
+ "llm": {
118
+ "type": "object",
119
+ "required": ["response_text", "response_hash"],
120
+ "properties": {
121
+ "response_text": {"type": "string"},
122
+ "response_hash": {"type": "string"}
123
+ }
124
+ },
125
+ "action": {
126
+ "type": "object",
127
+ "required": ["kind"],
128
+ "properties": {
129
+ "kind": {"type": "string", "enum": ["click", "type", "press", "finish", "navigate"]},
130
+ "element_id": {"type": "integer"},
131
+ "text": {"type": "string"},
132
+ "key": {"type": "string"},
133
+ "url": {"type": "string"},
134
+ "raw": {"type": "string"}
135
+ }
136
+ },
137
+ "exec": {
138
+ "type": "object",
139
+ "required": ["success", "outcome", "duration_ms"],
140
+ "properties": {
141
+ "success": {"type": "boolean"},
142
+ "outcome": {"type": "string"},
143
+ "action": {"type": "string"},
144
+ "element_id": {"type": "integer"},
145
+ "text": {"type": "string"},
146
+ "key": {"type": "string"},
147
+ "url_changed": {"type": ["boolean", "null"]},
148
+ "duration_ms": {"type": "integer"}
149
+ }
150
+ },
151
+ "post": {
152
+ "type": "object",
153
+ "properties": {
154
+ "url": {"type": ["string", "null"]},
155
+ "snapshot_digest": {"type": "string"},
156
+ "snapshot_digest_loose": {"type": "string"}
157
+ }
158
+ },
159
+ "verify": {
160
+ "type": "object",
161
+ "required": ["passed"],
162
+ "properties": {
163
+ "policy": {"type": "string"},
164
+ "passed": {"type": "boolean"},
165
+ "signals": {"type": "object"}
166
+ }
167
+ },
168
+ "recovery": {
169
+ "type": ["object", "null"],
170
+ "properties": {
171
+ "attempted": {"type": "boolean"},
172
+ "success": {"type": "boolean"},
173
+ "strategy": {"type": "string"},
174
+ "attempts": {"type": "array"}
175
+ }
176
+ }
177
+ }
178
+ },
179
+ {
180
+ "description": "verification data",
181
+ "required": ["step_id", "passed"],
182
+ "properties": {
183
+ "step_id": {"type": "string"},
184
+ "passed": {"type": "boolean"},
185
+ "signals": {"type": "object"}
186
+ }
187
+ },
188
+ {
189
+ "description": "recovery data",
190
+ "required": ["step_id", "strategy"],
191
+ "properties": {
192
+ "step_id": {"type": "string"},
193
+ "strategy": {"type": "string"},
194
+ "attempt": {"type": "integer"}
195
+ }
196
+ },
197
+ {
198
+ "description": "run_end data",
199
+ "required": ["steps"],
200
+ "properties": {
201
+ "steps": {"type": "integer"}
202
+ }
203
+ },
204
+ {
205
+ "description": "error data",
206
+ "required": ["step_id", "error"],
207
+ "properties": {
208
+ "step_id": {"type": "string"},
209
+ "attempt": {"type": "integer"},
210
+ "error": {"type": "string"}
211
+ }
212
+ }
213
+ ]
214
+ }
215
+ }
216
+ }
@@ -0,0 +1,54 @@
1
+ """
2
+ Screenshot functionality - standalone screenshot capture
3
+ """
4
+
5
+ from typing import Any, Literal
6
+
7
+ from .browser import SentienceBrowser
8
+
9
+
10
+ def screenshot(
11
+ browser: SentienceBrowser,
12
+ format: Literal["png", "jpeg"] = "png",
13
+ quality: int | None = None,
14
+ ) -> str:
15
+ """
16
+ Capture screenshot of current page
17
+
18
+ Args:
19
+ browser: SentienceBrowser instance
20
+ format: Image format - "png" or "jpeg"
21
+ quality: JPEG quality (1-100), only used for JPEG format
22
+
23
+ Returns:
24
+ Base64-encoded screenshot data URL (e.g., "data:image/png;base64,...")
25
+
26
+ Raises:
27
+ RuntimeError: If browser not started
28
+ ValueError: If quality is invalid for JPEG
29
+ """
30
+ if not browser.page:
31
+ raise RuntimeError("Browser not started. Call browser.start() first.")
32
+
33
+ if format == "jpeg" and quality is not None:
34
+ if not (1 <= quality <= 100):
35
+ raise ValueError("Quality must be between 1 and 100 for JPEG format")
36
+
37
+ # Use Playwright's screenshot with base64 encoding
38
+ screenshot_options: dict[str, Any] = {
39
+ "type": format,
40
+ }
41
+
42
+ if format == "jpeg" and quality is not None:
43
+ screenshot_options["quality"] = quality
44
+
45
+ # Capture screenshot as base64
46
+ # Playwright returns bytes when encoding is not specified, so we encode manually
47
+ import base64
48
+
49
+ image_bytes = browser.page.screenshot(**screenshot_options)
50
+ base64_data = base64.b64encode(image_bytes).decode("utf-8")
51
+
52
+ # Return as data URL
53
+ mime_type = "image/png" if format == "png" else "image/jpeg"
54
+ return f"data:{mime_type};base64,{base64_data}"