sentienceapi 0.95.0__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 (82) hide show
  1. sentience/__init__.py +253 -0
  2. sentience/_extension_loader.py +195 -0
  3. sentience/action_executor.py +215 -0
  4. sentience/actions.py +1020 -0
  5. sentience/agent.py +1181 -0
  6. sentience/agent_config.py +46 -0
  7. sentience/agent_runtime.py +424 -0
  8. sentience/asserts/__init__.py +70 -0
  9. sentience/asserts/expect.py +621 -0
  10. sentience/asserts/query.py +383 -0
  11. sentience/async_api.py +108 -0
  12. sentience/backends/__init__.py +137 -0
  13. sentience/backends/actions.py +343 -0
  14. sentience/backends/browser_use_adapter.py +241 -0
  15. sentience/backends/cdp_backend.py +393 -0
  16. sentience/backends/exceptions.py +211 -0
  17. sentience/backends/playwright_backend.py +194 -0
  18. sentience/backends/protocol.py +216 -0
  19. sentience/backends/sentience_context.py +469 -0
  20. sentience/backends/snapshot.py +427 -0
  21. sentience/base_agent.py +196 -0
  22. sentience/browser.py +1215 -0
  23. sentience/browser_evaluator.py +299 -0
  24. sentience/canonicalization.py +207 -0
  25. sentience/cli.py +130 -0
  26. sentience/cloud_tracing.py +807 -0
  27. sentience/constants.py +6 -0
  28. sentience/conversational_agent.py +543 -0
  29. sentience/element_filter.py +136 -0
  30. sentience/expect.py +188 -0
  31. sentience/extension/background.js +104 -0
  32. sentience/extension/content.js +161 -0
  33. sentience/extension/injected_api.js +914 -0
  34. sentience/extension/manifest.json +36 -0
  35. sentience/extension/pkg/sentience_core.d.ts +51 -0
  36. sentience/extension/pkg/sentience_core.js +323 -0
  37. sentience/extension/pkg/sentience_core_bg.wasm +0 -0
  38. sentience/extension/pkg/sentience_core_bg.wasm.d.ts +10 -0
  39. sentience/extension/release.json +115 -0
  40. sentience/formatting.py +15 -0
  41. sentience/generator.py +202 -0
  42. sentience/inspector.py +367 -0
  43. sentience/llm_interaction_handler.py +191 -0
  44. sentience/llm_provider.py +875 -0
  45. sentience/llm_provider_utils.py +120 -0
  46. sentience/llm_response_builder.py +153 -0
  47. sentience/models.py +846 -0
  48. sentience/ordinal.py +280 -0
  49. sentience/overlay.py +222 -0
  50. sentience/protocols.py +228 -0
  51. sentience/query.py +303 -0
  52. sentience/read.py +188 -0
  53. sentience/recorder.py +589 -0
  54. sentience/schemas/trace_v1.json +335 -0
  55. sentience/screenshot.py +100 -0
  56. sentience/sentience_methods.py +86 -0
  57. sentience/snapshot.py +706 -0
  58. sentience/snapshot_diff.py +126 -0
  59. sentience/text_search.py +262 -0
  60. sentience/trace_event_builder.py +148 -0
  61. sentience/trace_file_manager.py +197 -0
  62. sentience/trace_indexing/__init__.py +27 -0
  63. sentience/trace_indexing/index_schema.py +199 -0
  64. sentience/trace_indexing/indexer.py +414 -0
  65. sentience/tracer_factory.py +322 -0
  66. sentience/tracing.py +449 -0
  67. sentience/utils/__init__.py +40 -0
  68. sentience/utils/browser.py +46 -0
  69. sentience/utils/element.py +257 -0
  70. sentience/utils/formatting.py +59 -0
  71. sentience/utils.py +296 -0
  72. sentience/verification.py +380 -0
  73. sentience/visual_agent.py +2058 -0
  74. sentience/wait.py +139 -0
  75. sentienceapi-0.95.0.dist-info/METADATA +984 -0
  76. sentienceapi-0.95.0.dist-info/RECORD +82 -0
  77. sentienceapi-0.95.0.dist-info/WHEEL +5 -0
  78. sentienceapi-0.95.0.dist-info/entry_points.txt +2 -0
  79. sentienceapi-0.95.0.dist-info/licenses/LICENSE +24 -0
  80. sentienceapi-0.95.0.dist-info/licenses/LICENSE-APACHE +201 -0
  81. sentienceapi-0.95.0.dist-info/licenses/LICENSE-MIT +21 -0
  82. sentienceapi-0.95.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,257 @@
1
+ """
2
+ Element manipulation and digest utilities for Sentience SDK.
3
+
4
+ Provides functions to compute stable digests of snapshots for deterministic diff.
5
+ Two digest strategies:
6
+ - strict: includes structure + normalized text
7
+ - loose: structure only (no text) - detects layout changes vs content changes
8
+ """
9
+
10
+ import hashlib
11
+ import json
12
+ import re
13
+ from dataclasses import dataclass
14
+ from typing import Any, Optional
15
+
16
+
17
+ @dataclass
18
+ class BBox:
19
+ """Bounding box with normalized coordinates."""
20
+
21
+ x: int
22
+ y: int
23
+ width: int
24
+ height: int
25
+
26
+ @classmethod
27
+ def from_dict(cls, bbox_dict: dict[str, Any]) -> "BBox":
28
+ """Create BBox from dictionary."""
29
+ return cls(
30
+ x=int(bbox_dict.get("x", 0)),
31
+ y=int(bbox_dict.get("y", 0)),
32
+ width=int(bbox_dict.get("width", 0)),
33
+ height=int(bbox_dict.get("height", 0)),
34
+ )
35
+
36
+ def to_normalized(self, bucket_size: int = 2) -> list[int]:
37
+ """
38
+ Normalize bbox to fixed-size buckets to ignore minor jitter.
39
+
40
+ Args:
41
+ bucket_size: Pixel bucket size (default 2px)
42
+
43
+ Returns:
44
+ List of [x, y, width, height] rounded to buckets
45
+ """
46
+ return [
47
+ round(self.x / bucket_size) * bucket_size,
48
+ round(self.y / bucket_size) * bucket_size,
49
+ round(self.width / bucket_size) * bucket_size,
50
+ round(self.height / bucket_size) * bucket_size,
51
+ ]
52
+
53
+
54
+ @dataclass
55
+ class ElementFingerprint:
56
+ """Normalized element data for digest computation."""
57
+
58
+ id: int
59
+ role: str
60
+ bbox: list[int] # Normalized
61
+ clickable: int # 0 or 1
62
+ primary: int # 0 or 1
63
+ text: str = "" # Empty for loose digest
64
+
65
+ def to_dict(self) -> dict[str, Any]:
66
+ """Convert to dictionary for JSON serialization."""
67
+ data = {
68
+ "id": self.id,
69
+ "role": self.role,
70
+ "bbox": self.bbox,
71
+ "clickable": self.clickable,
72
+ "primary": self.primary,
73
+ }
74
+ if self.text: # Only include text if non-empty
75
+ data["text"] = self.text
76
+ return data
77
+
78
+
79
+ def normalize_text_strict(text: str | None, max_length: int = 80) -> str:
80
+ """
81
+ Normalize text for strict digest (structure + content).
82
+
83
+ Rules:
84
+ - Lowercase
85
+ - Trim and collapse whitespace
86
+ - Cap length at max_length
87
+ - Replace digit runs with '#'
88
+ - Normalize currency: $79.99 -> $#
89
+ - Normalize time patterns: 12:34 -> #:#
90
+
91
+ Args:
92
+ text: Input text
93
+ max_length: Maximum text length (default 80)
94
+
95
+ Returns:
96
+ Normalized text string
97
+ """
98
+ if not text:
99
+ return ""
100
+
101
+ # Lowercase and trim
102
+ text = text.strip().lower()
103
+
104
+ # Collapse whitespace
105
+ text = " ".join(text.split())
106
+
107
+ # Cap length
108
+ text = text[:max_length]
109
+
110
+ # Replace digit runs with #
111
+ text = re.sub(r"\d+", "#", text)
112
+
113
+ # Normalize currency
114
+ text = re.sub(r"\$\s*#", "$#", text)
115
+
116
+ # Normalize time patterns (HH:MM or similar)
117
+ text = re.sub(r"#:#", "#:#", text)
118
+
119
+ # Normalize date patterns (YYYY-MM-DD or similar)
120
+ text = re.sub(r"#-#-#", "#-#-#", text)
121
+
122
+ return text
123
+
124
+
125
+ def normalize_bbox(bbox: dict[str, Any] | BBox, bucket_size: int = 2) -> list[int]:
126
+ """
127
+ Round bbox to fixed-size buckets to ignore jitter.
128
+
129
+ Args:
130
+ bbox: BBox object or dict with x, y, width, height
131
+ bucket_size: Pixel bucket size (default 2px)
132
+
133
+ Returns:
134
+ List of [x, y, width, height] rounded to buckets
135
+ """
136
+ if isinstance(bbox, BBox):
137
+ return bbox.to_normalized(bucket_size)
138
+
139
+ bbox_obj = BBox.from_dict(bbox)
140
+ return bbox_obj.to_normalized(bucket_size)
141
+
142
+
143
+ def extract_element_fingerprint(
144
+ element: dict[str, Any],
145
+ include_text: bool = True,
146
+ ) -> ElementFingerprint:
147
+ """
148
+ Extract normalized fingerprint from element dict.
149
+
150
+ Args:
151
+ element: Element dict from snapshot
152
+ include_text: Whether to include normalized text (False for loose digest)
153
+
154
+ Returns:
155
+ ElementFingerprint with normalized data
156
+ """
157
+ # Extract basic fields
158
+ element_id = element.get("id", 0)
159
+ role = element.get("role", "unknown")
160
+
161
+ # Extract and normalize bbox
162
+ bbox_data = element.get("bbox", {})
163
+ bbox_normalized = normalize_bbox(bbox_data)
164
+
165
+ # Extract visual cues
166
+ visual_cues = element.get("visual_cues", {})
167
+ clickable = 1 if visual_cues.get("is_clickable", False) else 0
168
+ primary = 1 if visual_cues.get("is_primary", False) else 0
169
+
170
+ # Extract and normalize text (if requested)
171
+ text = ""
172
+ if include_text:
173
+ raw_text = element.get("text", "")
174
+ text = normalize_text_strict(raw_text)
175
+
176
+ return ElementFingerprint(
177
+ id=element_id,
178
+ role=role,
179
+ bbox=bbox_normalized,
180
+ clickable=clickable,
181
+ primary=primary,
182
+ text=text,
183
+ )
184
+
185
+
186
+ def canonical_snapshot_strict(elements: list[dict[str, Any]]) -> str:
187
+ """
188
+ Create strict snapshot digest (structure + normalized text).
189
+
190
+ Args:
191
+ elements: List of element dicts from snapshot
192
+
193
+ Returns:
194
+ Canonical JSON string for hashing
195
+ """
196
+ fingerprints = []
197
+
198
+ for element in sorted(elements, key=lambda e: e.get("id", 0)):
199
+ fingerprint = extract_element_fingerprint(element, include_text=True)
200
+ fingerprints.append(fingerprint.to_dict())
201
+
202
+ return json.dumps(fingerprints, sort_keys=True, ensure_ascii=False)
203
+
204
+
205
+ def canonical_snapshot_loose(elements: list[dict[str, Any]]) -> str:
206
+ """
207
+ Create loose snapshot digest (structure only, no text).
208
+
209
+ This is more resistant to content churn (prices, ads, timestamps).
210
+ Use for detecting structural changes vs content changes.
211
+
212
+ Args:
213
+ elements: List of element dicts from snapshot
214
+
215
+ Returns:
216
+ Canonical JSON string for hashing
217
+ """
218
+ fingerprints = []
219
+
220
+ for element in sorted(elements, key=lambda e: e.get("id", 0)):
221
+ fingerprint = extract_element_fingerprint(element, include_text=False)
222
+ fingerprints.append(fingerprint.to_dict())
223
+
224
+ return json.dumps(fingerprints, sort_keys=True, ensure_ascii=False)
225
+
226
+
227
+ def sha256_digest(canonical_str: str) -> str:
228
+ """
229
+ Compute SHA256 hash with 'sha256:' prefix.
230
+
231
+ Args:
232
+ canonical_str: Canonical string to hash
233
+
234
+ Returns:
235
+ Hash string with format: "sha256:<hex>"
236
+ """
237
+ hash_obj = hashlib.sha256(canonical_str.encode("utf-8"))
238
+ return f"sha256:{hash_obj.hexdigest()}"
239
+
240
+
241
+ def compute_snapshot_digests(elements: list[dict[str, Any]]) -> dict[str, str]:
242
+ """
243
+ Compute both strict and loose digests for a snapshot.
244
+
245
+ Args:
246
+ elements: List of element dicts from snapshot
247
+
248
+ Returns:
249
+ Dict with 'strict' and 'loose' digest strings
250
+ """
251
+ canonical_strict = canonical_snapshot_strict(elements)
252
+ canonical_loose = canonical_snapshot_loose(elements)
253
+
254
+ return {
255
+ "strict": sha256_digest(canonical_strict),
256
+ "loose": sha256_digest(canonical_loose),
257
+ }
@@ -0,0 +1,59 @@
1
+ """
2
+ Snapshot formatting utilities for LLM prompts.
3
+
4
+ Provides functions to convert Sentience snapshots into text format suitable
5
+ for LLM consumption.
6
+ """
7
+
8
+ from typing import List
9
+
10
+ from ..models import Snapshot
11
+
12
+
13
+ def format_snapshot_for_llm(snap: Snapshot, limit: int = 50) -> str:
14
+ """
15
+ Convert snapshot elements to text format for LLM consumption.
16
+
17
+ This is the canonical way Sentience formats DOM state for LLMs.
18
+ The format includes element ID, role, text preview, visual cues,
19
+ position, and importance score.
20
+
21
+ Args:
22
+ snap: Snapshot object with elements
23
+ limit: Maximum number of elements to include (default: 50)
24
+
25
+ Returns:
26
+ Formatted string with one element per line
27
+
28
+ Example:
29
+ >>> snap = snapshot(browser)
30
+ >>> formatted = format_snapshot_for_llm(snap, limit=10)
31
+ >>> print(formatted)
32
+ [1] <button> "Sign In" {PRIMARY,CLICKABLE} @ (100,50) (Imp:10)
33
+ [2] <input> "Email address" @ (100,100) (Imp:8)
34
+ ...
35
+ """
36
+ lines: list[str] = []
37
+
38
+ for el in snap.elements[:limit]:
39
+ # Build visual cues string
40
+ cues = []
41
+ if getattr(el.visual_cues, "is_primary", False):
42
+ cues.append("PRIMARY")
43
+ if getattr(el.visual_cues, "is_clickable", False):
44
+ cues.append("CLICKABLE")
45
+
46
+ cues_str = f" {{{','.join(cues)}}}" if cues else ""
47
+
48
+ # Format text preview (truncate to 50 chars)
49
+ text_preview = el.text or ""
50
+ if len(text_preview) > 50:
51
+ text_preview = text_preview[:50] + "..."
52
+
53
+ # Build element line: [ID] <role> "text" {cues} @ (x,y) (Imp:score)
54
+ lines.append(
55
+ f'[{el.id}] <{el.role}> "{text_preview}"{cues_str} '
56
+ f"@ ({int(el.bbox.x)},{int(el.bbox.y)}) (Imp:{el.importance})"
57
+ )
58
+
59
+ return "\n".join(lines)
sentience/utils.py ADDED
@@ -0,0 +1,296 @@
1
+ """
2
+ Digest utilities for snapshot canonicalization and hashing.
3
+
4
+ Provides functions to compute stable digests of snapshots for determinism diff.
5
+ Two digest strategies:
6
+ - strict: includes structure + normalized text
7
+ - loose: structure only (no text) - detects layout changes vs content changes
8
+ """
9
+
10
+ import hashlib
11
+ import json
12
+ import re
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any, Optional
16
+
17
+ from playwright.sync_api import BrowserContext
18
+
19
+
20
+ @dataclass
21
+ class BBox:
22
+ """Bounding box with normalized coordinates."""
23
+
24
+ x: int
25
+ y: int
26
+ width: int
27
+ height: int
28
+
29
+ @classmethod
30
+ def from_dict(cls, bbox_dict: dict[str, Any]) -> "BBox":
31
+ """Create BBox from dictionary."""
32
+ return cls(
33
+ x=int(bbox_dict.get("x", 0)),
34
+ y=int(bbox_dict.get("y", 0)),
35
+ width=int(bbox_dict.get("width", 0)),
36
+ height=int(bbox_dict.get("height", 0)),
37
+ )
38
+
39
+ def to_normalized(self, bucket_size: int = 2) -> list[int]:
40
+ """
41
+ Normalize bbox to fixed-size buckets to ignore minor jitter.
42
+
43
+ Args:
44
+ bucket_size: Pixel bucket size (default 2px)
45
+
46
+ Returns:
47
+ List of [x, y, width, height] rounded to buckets
48
+ """
49
+ return [
50
+ round(self.x / bucket_size) * bucket_size,
51
+ round(self.y / bucket_size) * bucket_size,
52
+ round(self.width / bucket_size) * bucket_size,
53
+ round(self.height / bucket_size) * bucket_size,
54
+ ]
55
+
56
+
57
+ @dataclass
58
+ class ElementFingerprint:
59
+ """Normalized element data for digest computation."""
60
+
61
+ id: int
62
+ role: str
63
+ bbox: list[int] # Normalized
64
+ clickable: int # 0 or 1
65
+ primary: int # 0 or 1
66
+ text: str = "" # Empty for loose digest
67
+
68
+ def to_dict(self) -> dict[str, Any]:
69
+ """Convert to dictionary for JSON serialization."""
70
+ data = {
71
+ "id": self.id,
72
+ "role": self.role,
73
+ "bbox": self.bbox,
74
+ "clickable": self.clickable,
75
+ "primary": self.primary,
76
+ }
77
+ if self.text: # Only include text if non-empty
78
+ data["text"] = self.text
79
+ return data
80
+
81
+
82
+ def normalize_text_strict(text: str | None, max_length: int = 80) -> str:
83
+ """
84
+ Normalize text for strict digest (structure + content).
85
+
86
+ Rules:
87
+ - Lowercase
88
+ - Trim and collapse whitespace
89
+ - Cap length at max_length
90
+ - Replace digit runs with '#'
91
+ - Normalize currency: $79.99 -> $#
92
+ - Normalize time patterns: 12:34 -> #:#
93
+
94
+ Args:
95
+ text: Input text
96
+ max_length: Maximum text length (default 80)
97
+
98
+ Returns:
99
+ Normalized text string
100
+ """
101
+ if not text:
102
+ return ""
103
+
104
+ # Lowercase and trim
105
+ text = text.strip().lower()
106
+
107
+ # Collapse whitespace
108
+ text = " ".join(text.split())
109
+
110
+ # Cap length
111
+ text = text[:max_length]
112
+
113
+ # Replace digit runs with #
114
+ text = re.sub(r"\d+", "#", text)
115
+
116
+ # Normalize currency
117
+ text = re.sub(r"\$\s*#", "$#", text)
118
+
119
+ # Normalize time patterns (HH:MM or similar)
120
+ text = re.sub(r"#:#", "#:#", text)
121
+
122
+ # Normalize date patterns (YYYY-MM-DD or similar)
123
+ text = re.sub(r"#-#-#", "#-#-#", text)
124
+
125
+ return text
126
+
127
+
128
+ def normalize_bbox(bbox: dict[str, Any] | BBox, bucket_size: int = 2) -> list[int]:
129
+ """
130
+ Round bbox to fixed-size buckets to ignore jitter.
131
+
132
+ Args:
133
+ bbox: BBox object or dict with x, y, width, height
134
+ bucket_size: Pixel bucket size (default 2px)
135
+
136
+ Returns:
137
+ List of [x, y, width, height] rounded to buckets
138
+ """
139
+ if isinstance(bbox, BBox):
140
+ return bbox.to_normalized(bucket_size)
141
+
142
+ bbox_obj = BBox.from_dict(bbox)
143
+ return bbox_obj.to_normalized(bucket_size)
144
+
145
+
146
+ def extract_element_fingerprint(
147
+ element: dict[str, Any],
148
+ include_text: bool = True,
149
+ ) -> ElementFingerprint:
150
+ """
151
+ Extract normalized fingerprint from element dict.
152
+
153
+ Args:
154
+ element: Element dict from snapshot
155
+ include_text: Whether to include normalized text (False for loose digest)
156
+
157
+ Returns:
158
+ ElementFingerprint with normalized data
159
+ """
160
+ # Extract basic fields
161
+ element_id = element.get("id", 0)
162
+ role = element.get("role", "unknown")
163
+
164
+ # Extract and normalize bbox
165
+ bbox_data = element.get("bbox", {})
166
+ bbox_normalized = normalize_bbox(bbox_data)
167
+
168
+ # Extract visual cues
169
+ visual_cues = element.get("visual_cues", {})
170
+ clickable = 1 if visual_cues.get("is_clickable", False) else 0
171
+ primary = 1 if visual_cues.get("is_primary", False) else 0
172
+
173
+ # Extract and normalize text (if requested)
174
+ text = ""
175
+ if include_text:
176
+ raw_text = element.get("text", "")
177
+ text = normalize_text_strict(raw_text)
178
+
179
+ return ElementFingerprint(
180
+ id=element_id,
181
+ role=role,
182
+ bbox=bbox_normalized,
183
+ clickable=clickable,
184
+ primary=primary,
185
+ text=text,
186
+ )
187
+
188
+
189
+ def canonical_snapshot_strict(elements: list[dict[str, Any]]) -> str:
190
+ """
191
+ Create strict snapshot digest (structure + normalized text).
192
+
193
+ Args:
194
+ elements: List of element dicts from snapshot
195
+
196
+ Returns:
197
+ Canonical JSON string for hashing
198
+ """
199
+ fingerprints = []
200
+
201
+ for element in sorted(elements, key=lambda e: e.get("id", 0)):
202
+ fingerprint = extract_element_fingerprint(element, include_text=True)
203
+ fingerprints.append(fingerprint.to_dict())
204
+
205
+ return json.dumps(fingerprints, sort_keys=True, ensure_ascii=False)
206
+
207
+
208
+ def canonical_snapshot_loose(elements: list[dict[str, Any]]) -> str:
209
+ """
210
+ Create loose snapshot digest (structure only, no text).
211
+
212
+ This is more resistant to content churn (prices, ads, timestamps).
213
+ Use for detecting structural changes vs content changes.
214
+
215
+ Args:
216
+ elements: List of element dicts from snapshot
217
+
218
+ Returns:
219
+ Canonical JSON string for hashing
220
+ """
221
+ fingerprints = []
222
+
223
+ for element in sorted(elements, key=lambda e: e.get("id", 0)):
224
+ fingerprint = extract_element_fingerprint(element, include_text=False)
225
+ fingerprints.append(fingerprint.to_dict())
226
+
227
+ return json.dumps(fingerprints, sort_keys=True, ensure_ascii=False)
228
+
229
+
230
+ def sha256_digest(canonical_str: str) -> str:
231
+ """
232
+ Compute SHA256 hash with 'sha256:' prefix.
233
+
234
+ Args:
235
+ canonical_str: Canonical string to hash
236
+
237
+ Returns:
238
+ Hash string with format: "sha256:<hex>"
239
+ """
240
+ hash_obj = hashlib.sha256(canonical_str.encode("utf-8"))
241
+ return f"sha256:{hash_obj.hexdigest()}"
242
+
243
+
244
+ def compute_snapshot_digests(elements: list[dict[str, Any]]) -> dict[str, str]:
245
+ """
246
+ Compute both strict and loose digests for a snapshot.
247
+
248
+ Args:
249
+ elements: List of element dicts from snapshot
250
+
251
+ Returns:
252
+ Dict with 'strict' and 'loose' digest strings
253
+ """
254
+ canonical_strict = canonical_snapshot_strict(elements)
255
+ canonical_loose = canonical_snapshot_loose(elements)
256
+
257
+ return {
258
+ "strict": sha256_digest(canonical_strict),
259
+ "loose": sha256_digest(canonical_loose),
260
+ }
261
+
262
+
263
+ def save_storage_state(context: BrowserContext, file_path: str | Path) -> None:
264
+ """
265
+ Save current browser storage state (cookies + localStorage) to a file.
266
+
267
+ This is useful for capturing a logged-in session to reuse later.
268
+
269
+ Args:
270
+ context: Playwright BrowserContext
271
+ file_path: Path to save the storage state JSON file
272
+
273
+ Example:
274
+ ```python
275
+ from sentience import SentienceBrowser, save_storage_state
276
+
277
+ browser = SentienceBrowser()
278
+ browser.start()
279
+
280
+ # User logs in manually or via agent
281
+ browser.goto("https://example.com")
282
+ # ... login happens ...
283
+
284
+ # Save session for later
285
+ save_storage_state(browser.context, "auth.json")
286
+ ```
287
+
288
+ Raises:
289
+ IOError: If file cannot be written
290
+ """
291
+ storage_state = context.storage_state()
292
+ file_path_obj = Path(file_path)
293
+ file_path_obj.parent.mkdir(parents=True, exist_ok=True)
294
+ with open(file_path_obj, "w") as f:
295
+ json.dump(storage_state, f, indent=2)
296
+ print(f"✅ [Sentience] Saved storage state to {file_path_obj}")