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.
- sentience/__init__.py +253 -0
- sentience/_extension_loader.py +195 -0
- sentience/action_executor.py +215 -0
- sentience/actions.py +1020 -0
- sentience/agent.py +1181 -0
- sentience/agent_config.py +46 -0
- sentience/agent_runtime.py +424 -0
- sentience/asserts/__init__.py +70 -0
- sentience/asserts/expect.py +621 -0
- sentience/asserts/query.py +383 -0
- sentience/async_api.py +108 -0
- sentience/backends/__init__.py +137 -0
- sentience/backends/actions.py +343 -0
- sentience/backends/browser_use_adapter.py +241 -0
- sentience/backends/cdp_backend.py +393 -0
- sentience/backends/exceptions.py +211 -0
- sentience/backends/playwright_backend.py +194 -0
- sentience/backends/protocol.py +216 -0
- sentience/backends/sentience_context.py +469 -0
- sentience/backends/snapshot.py +427 -0
- sentience/base_agent.py +196 -0
- sentience/browser.py +1215 -0
- sentience/browser_evaluator.py +299 -0
- sentience/canonicalization.py +207 -0
- sentience/cli.py +130 -0
- sentience/cloud_tracing.py +807 -0
- sentience/constants.py +6 -0
- sentience/conversational_agent.py +543 -0
- sentience/element_filter.py +136 -0
- sentience/expect.py +188 -0
- sentience/extension/background.js +104 -0
- sentience/extension/content.js +161 -0
- sentience/extension/injected_api.js +914 -0
- sentience/extension/manifest.json +36 -0
- sentience/extension/pkg/sentience_core.d.ts +51 -0
- sentience/extension/pkg/sentience_core.js +323 -0
- sentience/extension/pkg/sentience_core_bg.wasm +0 -0
- sentience/extension/pkg/sentience_core_bg.wasm.d.ts +10 -0
- sentience/extension/release.json +115 -0
- sentience/formatting.py +15 -0
- sentience/generator.py +202 -0
- sentience/inspector.py +367 -0
- sentience/llm_interaction_handler.py +191 -0
- sentience/llm_provider.py +875 -0
- sentience/llm_provider_utils.py +120 -0
- sentience/llm_response_builder.py +153 -0
- sentience/models.py +846 -0
- sentience/ordinal.py +280 -0
- sentience/overlay.py +222 -0
- sentience/protocols.py +228 -0
- sentience/query.py +303 -0
- sentience/read.py +188 -0
- sentience/recorder.py +589 -0
- sentience/schemas/trace_v1.json +335 -0
- sentience/screenshot.py +100 -0
- sentience/sentience_methods.py +86 -0
- sentience/snapshot.py +706 -0
- sentience/snapshot_diff.py +126 -0
- sentience/text_search.py +262 -0
- sentience/trace_event_builder.py +148 -0
- sentience/trace_file_manager.py +197 -0
- sentience/trace_indexing/__init__.py +27 -0
- sentience/trace_indexing/index_schema.py +199 -0
- sentience/trace_indexing/indexer.py +414 -0
- sentience/tracer_factory.py +322 -0
- sentience/tracing.py +449 -0
- sentience/utils/__init__.py +40 -0
- sentience/utils/browser.py +46 -0
- sentience/utils/element.py +257 -0
- sentience/utils/formatting.py +59 -0
- sentience/utils.py +296 -0
- sentience/verification.py +380 -0
- sentience/visual_agent.py +2058 -0
- sentience/wait.py +139 -0
- sentienceapi-0.95.0.dist-info/METADATA +984 -0
- sentienceapi-0.95.0.dist-info/RECORD +82 -0
- sentienceapi-0.95.0.dist-info/WHEEL +5 -0
- sentienceapi-0.95.0.dist-info/entry_points.txt +2 -0
- sentienceapi-0.95.0.dist-info/licenses/LICENSE +24 -0
- sentienceapi-0.95.0.dist-info/licenses/LICENSE-APACHE +201 -0
- sentienceapi-0.95.0.dist-info/licenses/LICENSE-MIT +21 -0
- sentienceapi-0.95.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SentienceContext: Token-Slasher Context Middleware for browser-use.
|
|
3
|
+
|
|
4
|
+
This module provides a compact, ranked DOM context block for browser-use agents,
|
|
5
|
+
reducing tokens and improving reliability by using Sentience snapshots.
|
|
6
|
+
|
|
7
|
+
Example usage:
|
|
8
|
+
from browser_use import Agent
|
|
9
|
+
from sentience.backends import SentienceContext
|
|
10
|
+
|
|
11
|
+
ctx = SentienceContext(show_overlay=True)
|
|
12
|
+
state = await ctx.build(agent.browser_session, goal="Click the first Show HN post")
|
|
13
|
+
if state:
|
|
14
|
+
agent.add_context(state.prompt_block) # or however browser-use injects state
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import logging
|
|
21
|
+
import re
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
from urllib.parse import urlparse
|
|
25
|
+
|
|
26
|
+
from ..constants import SENTIENCE_API_URL
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from ..models import Element, Snapshot
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class SentienceContextState:
|
|
36
|
+
"""Sentience context state with snapshot and formatted prompt block."""
|
|
37
|
+
|
|
38
|
+
url: str
|
|
39
|
+
snapshot: Snapshot
|
|
40
|
+
prompt_block: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class TopElementSelector:
|
|
45
|
+
"""
|
|
46
|
+
Configuration for element selection strategy.
|
|
47
|
+
|
|
48
|
+
The selector uses a 3-way merge to pick elements for the LLM context:
|
|
49
|
+
1. Top N by importance score (most actionable elements)
|
|
50
|
+
2. Top N from dominant group (for ordinal tasks like "click 3rd item")
|
|
51
|
+
3. Top N by position (elements at top of page, lowest doc_y)
|
|
52
|
+
|
|
53
|
+
Elements are deduplicated across all three sources.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
by_importance: int = 60
|
|
57
|
+
"""Number of top elements to select by importance score (descending)."""
|
|
58
|
+
|
|
59
|
+
from_dominant_group: int = 15
|
|
60
|
+
"""Number of top elements to select from the dominant group (for ordinal tasks)."""
|
|
61
|
+
|
|
62
|
+
by_position: int = 10
|
|
63
|
+
"""Number of top elements to select by position (lowest doc_y = top of page)."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SentienceContext:
|
|
67
|
+
"""
|
|
68
|
+
Token-Slasher Context Middleware for browser-use.
|
|
69
|
+
|
|
70
|
+
Creates a compact, ranked DOM context block using Sentience snapshots,
|
|
71
|
+
reducing tokens and improving reliability for LLM-based browser agents.
|
|
72
|
+
|
|
73
|
+
Example:
|
|
74
|
+
from browser_use import Agent
|
|
75
|
+
from sentience.backends import SentienceContext
|
|
76
|
+
|
|
77
|
+
ctx = SentienceContext(show_overlay=True)
|
|
78
|
+
state = await ctx.build(agent.browser_session, goal="Click the first Show HN post")
|
|
79
|
+
if state:
|
|
80
|
+
agent.add_context(state.prompt_block)
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
# Sentience API endpoint
|
|
84
|
+
API_URL = SENTIENCE_API_URL
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
sentience_api_key: str | None = None,
|
|
90
|
+
use_api: bool | None = None,
|
|
91
|
+
max_elements: int = 60,
|
|
92
|
+
show_overlay: bool = False,
|
|
93
|
+
top_element_selector: TopElementSelector | None = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Initialize SentienceContext.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
sentience_api_key: Sentience API key for gateway mode
|
|
100
|
+
use_api: Force API vs extension mode (auto-detected if None)
|
|
101
|
+
max_elements: Maximum elements to fetch from snapshot
|
|
102
|
+
show_overlay: Show visual overlay highlighting elements in browser
|
|
103
|
+
top_element_selector: Configuration for element selection strategy
|
|
104
|
+
"""
|
|
105
|
+
self._api_key = sentience_api_key
|
|
106
|
+
self._use_api = use_api
|
|
107
|
+
self._max_elements = max_elements
|
|
108
|
+
self._show_overlay = show_overlay
|
|
109
|
+
self._selector = top_element_selector or TopElementSelector()
|
|
110
|
+
|
|
111
|
+
async def build(
|
|
112
|
+
self,
|
|
113
|
+
browser_session: Any,
|
|
114
|
+
*,
|
|
115
|
+
goal: str | None = None,
|
|
116
|
+
wait_for_extension_ms: int = 5000,
|
|
117
|
+
retries: int = 2,
|
|
118
|
+
retry_delay_s: float = 1.0,
|
|
119
|
+
) -> SentienceContextState | None:
|
|
120
|
+
"""
|
|
121
|
+
Build context state from browser session.
|
|
122
|
+
|
|
123
|
+
Takes a snapshot using the Sentience extension and formats it for LLM consumption.
|
|
124
|
+
Returns None if snapshot fails (extension not loaded, timeout, etc.).
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
browser_session: Browser-use BrowserSession instance
|
|
128
|
+
goal: Optional goal/task description (passed to gateway for reranking)
|
|
129
|
+
wait_for_extension_ms: Maximum time to wait for extension injection
|
|
130
|
+
retries: Number of retry attempts on snapshot failure
|
|
131
|
+
retry_delay_s: Delay between retries in seconds
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
SentienceContextState with snapshot and formatted prompt, or None if failed
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
# Import here to avoid requiring sentience as a hard dependency
|
|
138
|
+
from ..models import SnapshotOptions
|
|
139
|
+
from .browser_use_adapter import BrowserUseAdapter
|
|
140
|
+
from .snapshot import snapshot
|
|
141
|
+
|
|
142
|
+
# Create adapter and backend
|
|
143
|
+
adapter = BrowserUseAdapter(browser_session)
|
|
144
|
+
backend = await adapter.create_backend()
|
|
145
|
+
|
|
146
|
+
# Wait for extension to inject (poll until ready or timeout)
|
|
147
|
+
await self._wait_for_extension(backend, timeout_ms=wait_for_extension_ms)
|
|
148
|
+
|
|
149
|
+
# Build snapshot options
|
|
150
|
+
options = SnapshotOptions(
|
|
151
|
+
limit=self._max_elements,
|
|
152
|
+
show_overlay=self._show_overlay,
|
|
153
|
+
goal=goal,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Set API options
|
|
157
|
+
if self._api_key:
|
|
158
|
+
options.sentience_api_key = self._api_key
|
|
159
|
+
if self._use_api is not None:
|
|
160
|
+
options.use_api = self._use_api
|
|
161
|
+
elif self._api_key:
|
|
162
|
+
options.use_api = True
|
|
163
|
+
|
|
164
|
+
# Take snapshot with retry logic
|
|
165
|
+
snap = None
|
|
166
|
+
last_error: Exception | None = None
|
|
167
|
+
|
|
168
|
+
for attempt in range(retries):
|
|
169
|
+
try:
|
|
170
|
+
snap = await snapshot(backend, options=options)
|
|
171
|
+
break # Success
|
|
172
|
+
except Exception as e:
|
|
173
|
+
last_error = e
|
|
174
|
+
if attempt < retries - 1:
|
|
175
|
+
logger.debug(
|
|
176
|
+
"Sentience snapshot attempt %d failed: %s, retrying...",
|
|
177
|
+
attempt + 1,
|
|
178
|
+
e,
|
|
179
|
+
)
|
|
180
|
+
await asyncio.sleep(retry_delay_s)
|
|
181
|
+
else:
|
|
182
|
+
logger.warning(
|
|
183
|
+
"Sentience snapshot failed after %d attempts: %s",
|
|
184
|
+
retries,
|
|
185
|
+
last_error,
|
|
186
|
+
)
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
if snap is None:
|
|
190
|
+
logger.warning("Sentience snapshot returned None")
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
# Get URL from snapshot
|
|
194
|
+
url = snap.url or ""
|
|
195
|
+
|
|
196
|
+
# Format for LLM
|
|
197
|
+
formatted = self._format_snapshot_for_llm(snap)
|
|
198
|
+
|
|
199
|
+
# Build prompt block
|
|
200
|
+
prompt = (
|
|
201
|
+
"Elements: ID|role|text|imp|is_primary|docYq|ord|DG|href\n"
|
|
202
|
+
"Rules: ordinal→DG=1 then ord asc; otherwise imp desc. "
|
|
203
|
+
"Use click(ID)/input_text(ID,...).\n"
|
|
204
|
+
f"{formatted}"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
logger.info(
|
|
208
|
+
"SentienceContext snapshot: %d elements URL=%s",
|
|
209
|
+
len(snap.elements),
|
|
210
|
+
url,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
return SentienceContextState(url=url, snapshot=snap, prompt_block=prompt)
|
|
214
|
+
|
|
215
|
+
except ImportError as e:
|
|
216
|
+
logger.warning("Sentience SDK not available: %s", e)
|
|
217
|
+
return None
|
|
218
|
+
except Exception as e:
|
|
219
|
+
logger.warning("Sentience snapshot skipped: %s", e)
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
def _format_snapshot_for_llm(self, snapshot: Snapshot) -> str:
|
|
223
|
+
"""
|
|
224
|
+
Format Sentience snapshot for LLM consumption.
|
|
225
|
+
|
|
226
|
+
Creates an ultra-compact inventory of interactive elements optimized
|
|
227
|
+
for minimal token usage. Uses 3-way selection: by importance,
|
|
228
|
+
from dominant group, and by position.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
snapshot: Sentience Snapshot object
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Formatted string with format: ID|role|text|imp|is_primary|docYq|ord|DG|href
|
|
235
|
+
"""
|
|
236
|
+
# Filter to interactive elements only
|
|
237
|
+
interactive_roles = {
|
|
238
|
+
"button",
|
|
239
|
+
"link",
|
|
240
|
+
"textbox",
|
|
241
|
+
"searchbox",
|
|
242
|
+
"combobox",
|
|
243
|
+
"checkbox",
|
|
244
|
+
"radio",
|
|
245
|
+
"slider",
|
|
246
|
+
"tab",
|
|
247
|
+
"menuitem",
|
|
248
|
+
"option",
|
|
249
|
+
"switch",
|
|
250
|
+
"cell",
|
|
251
|
+
"a",
|
|
252
|
+
"input",
|
|
253
|
+
"select",
|
|
254
|
+
"textarea",
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
interactive_elements: list[Element] = []
|
|
258
|
+
for el in snapshot.elements:
|
|
259
|
+
role = (el.role or "").lower()
|
|
260
|
+
if role in interactive_roles:
|
|
261
|
+
interactive_elements.append(el)
|
|
262
|
+
|
|
263
|
+
# Sort by importance (descending) for importance-based selection
|
|
264
|
+
interactive_elements.sort(key=lambda el: el.importance or 0, reverse=True)
|
|
265
|
+
|
|
266
|
+
# Get top N by importance (track by ID for deduplication)
|
|
267
|
+
selected_ids: set[int] = set()
|
|
268
|
+
selected_elements: list[Element] = []
|
|
269
|
+
|
|
270
|
+
for el in interactive_elements[: self._selector.by_importance]:
|
|
271
|
+
if el.id not in selected_ids:
|
|
272
|
+
selected_ids.add(el.id)
|
|
273
|
+
selected_elements.append(el)
|
|
274
|
+
|
|
275
|
+
# Get top elements from dominant group (for ordinal tasks)
|
|
276
|
+
# Prefer in_dominant_group field (uses fuzzy matching from gateway)
|
|
277
|
+
dominant_group_elements = [
|
|
278
|
+
el for el in interactive_elements if el.in_dominant_group is True
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
# Fallback to exact group_key match if in_dominant_group not populated
|
|
282
|
+
if not dominant_group_elements and snapshot.dominant_group_key:
|
|
283
|
+
dominant_group_elements = [
|
|
284
|
+
el for el in interactive_elements if el.group_key == snapshot.dominant_group_key
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
# Sort by group_index for ordinal ordering
|
|
288
|
+
dominant_group_elements.sort(key=lambda el: el.group_index or 999)
|
|
289
|
+
|
|
290
|
+
for el in dominant_group_elements[: self._selector.from_dominant_group]:
|
|
291
|
+
if el.id not in selected_ids:
|
|
292
|
+
selected_ids.add(el.id)
|
|
293
|
+
selected_elements.append(el)
|
|
294
|
+
|
|
295
|
+
# Get top elements by position (lowest doc_y = top of page)
|
|
296
|
+
def get_y_position(el: Element) -> float:
|
|
297
|
+
if el.doc_y is not None:
|
|
298
|
+
return el.doc_y
|
|
299
|
+
if el.bbox is not None:
|
|
300
|
+
return el.bbox.y
|
|
301
|
+
return float("inf")
|
|
302
|
+
|
|
303
|
+
elements_by_position = sorted(
|
|
304
|
+
interactive_elements, key=lambda el: (get_y_position(el), -(el.importance or 0))
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
for el in elements_by_position[: self._selector.by_position]:
|
|
308
|
+
if el.id not in selected_ids:
|
|
309
|
+
selected_ids.add(el.id)
|
|
310
|
+
selected_elements.append(el)
|
|
311
|
+
|
|
312
|
+
# Compute local rank_in_group for dominant group elements
|
|
313
|
+
rank_in_group_map: dict[int, int] = {}
|
|
314
|
+
if True: # Always compute rank_in_group
|
|
315
|
+
# Sort dominant group elements by position for rank computation
|
|
316
|
+
dg_elements_for_rank = [
|
|
317
|
+
el for el in interactive_elements if el.in_dominant_group is True
|
|
318
|
+
]
|
|
319
|
+
if not dg_elements_for_rank and snapshot.dominant_group_key:
|
|
320
|
+
dg_elements_for_rank = [
|
|
321
|
+
el for el in interactive_elements if el.group_key == snapshot.dominant_group_key
|
|
322
|
+
]
|
|
323
|
+
|
|
324
|
+
# Sort by (doc_y, bbox.y, bbox.x, -importance)
|
|
325
|
+
def rank_sort_key(el: Element) -> tuple[float, float, float, float]:
|
|
326
|
+
doc_y = el.doc_y if el.doc_y is not None else float("inf")
|
|
327
|
+
bbox_y = el.bbox.y if el.bbox else float("inf")
|
|
328
|
+
bbox_x = el.bbox.x if el.bbox else float("inf")
|
|
329
|
+
neg_importance = -(el.importance or 0)
|
|
330
|
+
return (doc_y, bbox_y, bbox_x, neg_importance)
|
|
331
|
+
|
|
332
|
+
dg_elements_for_rank.sort(key=rank_sort_key)
|
|
333
|
+
for rank, el in enumerate(dg_elements_for_rank):
|
|
334
|
+
rank_in_group_map[el.id] = rank
|
|
335
|
+
|
|
336
|
+
# Format lines
|
|
337
|
+
lines: list[str] = []
|
|
338
|
+
for el in selected_elements:
|
|
339
|
+
# Get role (override to "link" if element has href)
|
|
340
|
+
role = el.role or ""
|
|
341
|
+
if el.href:
|
|
342
|
+
role = "link"
|
|
343
|
+
elif not role:
|
|
344
|
+
# Generic fallback for interactive elements without explicit role
|
|
345
|
+
role = "element"
|
|
346
|
+
|
|
347
|
+
# Get name/text (truncate aggressively, normalize whitespace)
|
|
348
|
+
name = el.text or ""
|
|
349
|
+
# Remove newlines and normalize whitespace
|
|
350
|
+
name = re.sub(r"\s+", " ", name.strip())
|
|
351
|
+
if len(name) > 30:
|
|
352
|
+
name = name[:27] + "..."
|
|
353
|
+
|
|
354
|
+
# Extract fields
|
|
355
|
+
importance = el.importance or 0
|
|
356
|
+
doc_y = el.doc_y or 0
|
|
357
|
+
|
|
358
|
+
# is_primary: from visual_cues.is_primary (boolean)
|
|
359
|
+
is_primary = False
|
|
360
|
+
if el.visual_cues:
|
|
361
|
+
is_primary = el.visual_cues.is_primary or False
|
|
362
|
+
is_primary_flag = "1" if is_primary else "0"
|
|
363
|
+
|
|
364
|
+
# Pre-encode fields for compactness
|
|
365
|
+
# docYq: bucketed doc_y (round to nearest 200 for smaller numbers)
|
|
366
|
+
doc_yq = int(round(doc_y / 200)) if doc_y else 0
|
|
367
|
+
|
|
368
|
+
# Determine if in dominant group
|
|
369
|
+
in_dg = el.in_dominant_group
|
|
370
|
+
if in_dg is None and snapshot.dominant_group_key:
|
|
371
|
+
# Fallback for older gateway versions
|
|
372
|
+
in_dg = el.group_key == snapshot.dominant_group_key
|
|
373
|
+
|
|
374
|
+
# ord_val: rank_in_group if in dominant group
|
|
375
|
+
if in_dg and el.id in rank_in_group_map:
|
|
376
|
+
ord_val: str | int = rank_in_group_map[el.id]
|
|
377
|
+
else:
|
|
378
|
+
ord_val = "-"
|
|
379
|
+
|
|
380
|
+
# DG: 1 if dominant group, else 0
|
|
381
|
+
dg_flag = "1" if in_dg else "0"
|
|
382
|
+
|
|
383
|
+
# href: short token (domain or last path segment, or blank)
|
|
384
|
+
href = self._compress_href(el.href)
|
|
385
|
+
|
|
386
|
+
# Ultra-compact format: ID|role|text|imp|is_primary|docYq|ord|DG|href
|
|
387
|
+
line = f"{el.id}|{role}|{name}|{importance}|{is_primary_flag}|{doc_yq}|{ord_val}|{dg_flag}|{href}"
|
|
388
|
+
lines.append(line)
|
|
389
|
+
|
|
390
|
+
logger.debug(
|
|
391
|
+
"Formatted %d elements (top %d by importance + top %d from dominant group + top %d by position)",
|
|
392
|
+
len(lines),
|
|
393
|
+
self._selector.by_importance,
|
|
394
|
+
self._selector.from_dominant_group,
|
|
395
|
+
self._selector.by_position,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
return "\n".join(lines)
|
|
399
|
+
|
|
400
|
+
async def _wait_for_extension(
|
|
401
|
+
self,
|
|
402
|
+
backend: Any,
|
|
403
|
+
*,
|
|
404
|
+
timeout_ms: int = 5000,
|
|
405
|
+
poll_interval_ms: int = 100,
|
|
406
|
+
) -> bool:
|
|
407
|
+
"""
|
|
408
|
+
Wait for Sentience extension to be ready in the browser.
|
|
409
|
+
|
|
410
|
+
Polls window.sentience until it's defined or timeout is reached.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
backend: Browser backend with evaluate() method
|
|
414
|
+
timeout_ms: Maximum time to wait in milliseconds
|
|
415
|
+
poll_interval_ms: Interval between polls in milliseconds
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
True if extension is ready, False if timeout
|
|
419
|
+
"""
|
|
420
|
+
elapsed_ms = 0
|
|
421
|
+
poll_interval_s = poll_interval_ms / 1000
|
|
422
|
+
|
|
423
|
+
while elapsed_ms < timeout_ms:
|
|
424
|
+
try:
|
|
425
|
+
result = await backend.evaluate("typeof window.sentience !== 'undefined'")
|
|
426
|
+
if result is True:
|
|
427
|
+
logger.debug("Sentience extension ready after %dms", elapsed_ms)
|
|
428
|
+
return True
|
|
429
|
+
except Exception:
|
|
430
|
+
# Extension not ready yet, continue polling
|
|
431
|
+
pass
|
|
432
|
+
|
|
433
|
+
await asyncio.sleep(poll_interval_s)
|
|
434
|
+
elapsed_ms += poll_interval_ms
|
|
435
|
+
|
|
436
|
+
logger.warning("Sentience extension not ready after %dms timeout", timeout_ms)
|
|
437
|
+
return False
|
|
438
|
+
|
|
439
|
+
def _compress_href(self, href: str | None) -> str:
|
|
440
|
+
"""
|
|
441
|
+
Compress href into a short token for minimal tokens.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
href: Full URL or None
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
Short token (domain second-level or last path segment)
|
|
448
|
+
"""
|
|
449
|
+
if not href:
|
|
450
|
+
return ""
|
|
451
|
+
|
|
452
|
+
try:
|
|
453
|
+
parsed = urlparse(href)
|
|
454
|
+
if parsed.netloc:
|
|
455
|
+
# Extract second-level domain (e.g., "github" from "github.com")
|
|
456
|
+
parts = parsed.netloc.split(".")
|
|
457
|
+
if len(parts) >= 2:
|
|
458
|
+
return parts[-2][:10]
|
|
459
|
+
return parsed.netloc[:10]
|
|
460
|
+
elif parsed.path:
|
|
461
|
+
# Use last path segment
|
|
462
|
+
segments = [s for s in parsed.path.split("/") if s]
|
|
463
|
+
if segments:
|
|
464
|
+
return segments[-1][:10]
|
|
465
|
+
return "item"
|
|
466
|
+
except Exception:
|
|
467
|
+
pass
|
|
468
|
+
|
|
469
|
+
return "item"
|