sentienceapi 0.92.2__py3-none-any.whl → 0.98.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 +107 -2
- sentience/_extension_loader.py +156 -1
- sentience/action_executor.py +2 -0
- sentience/actions.py +354 -9
- sentience/agent.py +4 -0
- sentience/agent_runtime.py +840 -0
- sentience/asserts/__init__.py +70 -0
- sentience/asserts/expect.py +621 -0
- sentience/asserts/query.py +383 -0
- sentience/async_api.py +8 -1
- sentience/backends/__init__.py +137 -0
- sentience/backends/actions.py +372 -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 +483 -0
- sentience/browser.py +230 -74
- sentience/canonicalization.py +207 -0
- sentience/cloud_tracing.py +65 -24
- sentience/constants.py +6 -0
- sentience/cursor_policy.py +142 -0
- sentience/extension/content.js +35 -0
- sentience/extension/injected_api.js +310 -15
- sentience/extension/manifest.json +1 -1
- sentience/extension/pkg/sentience_core.d.ts +22 -22
- sentience/extension/pkg/sentience_core.js +192 -144
- sentience/extension/pkg/sentience_core_bg.wasm +0 -0
- sentience/extension/release.json +29 -29
- sentience/failure_artifacts.py +241 -0
- sentience/integrations/__init__.py +6 -0
- sentience/integrations/langchain/__init__.py +12 -0
- sentience/integrations/langchain/context.py +18 -0
- sentience/integrations/langchain/core.py +326 -0
- sentience/integrations/langchain/tools.py +180 -0
- sentience/integrations/models.py +46 -0
- sentience/integrations/pydanticai/__init__.py +15 -0
- sentience/integrations/pydanticai/deps.py +20 -0
- sentience/integrations/pydanticai/toolset.py +468 -0
- sentience/llm_provider.py +695 -18
- sentience/models.py +536 -3
- sentience/ordinal.py +280 -0
- sentience/query.py +66 -4
- sentience/schemas/trace_v1.json +27 -1
- sentience/snapshot.py +384 -93
- sentience/snapshot_diff.py +39 -54
- sentience/text_search.py +1 -0
- sentience/trace_event_builder.py +20 -1
- sentience/trace_indexing/indexer.py +3 -49
- sentience/tracer_factory.py +1 -3
- sentience/verification.py +618 -0
- sentience/visual_agent.py +3 -1
- {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/METADATA +198 -40
- sentienceapi-0.98.0.dist-info/RECORD +92 -0
- sentience/utils.py +0 -296
- sentienceapi-0.92.2.dist-info/RECORD +0 -65
- {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/WHEEL +0 -0
- {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/entry_points.txt +0 -0
- {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE +0 -0
- {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-APACHE +0 -0
- {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-MIT +0 -0
- {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared canonicalization utilities for snapshot comparison and indexing.
|
|
3
|
+
|
|
4
|
+
This module provides consistent normalization functions used by both:
|
|
5
|
+
- trace_indexing/indexer.py (for computing stable digests)
|
|
6
|
+
- snapshot_diff.py (for computing diff_status labels)
|
|
7
|
+
|
|
8
|
+
By sharing these helpers, we ensure consistent behavior:
|
|
9
|
+
- Same text normalization (whitespace, case, length)
|
|
10
|
+
- Same bbox rounding (2px precision)
|
|
11
|
+
- Same change detection thresholds
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def normalize_text(text: str | None, max_len: int = 80) -> str:
|
|
18
|
+
"""
|
|
19
|
+
Normalize text for canonical comparison.
|
|
20
|
+
|
|
21
|
+
Transforms:
|
|
22
|
+
- Trims leading/trailing whitespace
|
|
23
|
+
- Collapses internal whitespace to single spaces
|
|
24
|
+
- Lowercases
|
|
25
|
+
- Caps length
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
text: Input text (may be None)
|
|
29
|
+
max_len: Maximum length to retain (default: 80)
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Normalized text string (empty string if input is None)
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
>>> normalize_text(" Hello World ")
|
|
36
|
+
'hello world'
|
|
37
|
+
>>> normalize_text(None)
|
|
38
|
+
''
|
|
39
|
+
"""
|
|
40
|
+
if not text:
|
|
41
|
+
return ""
|
|
42
|
+
# Trim and collapse whitespace
|
|
43
|
+
normalized = " ".join(text.split())
|
|
44
|
+
# Lowercase
|
|
45
|
+
normalized = normalized.lower()
|
|
46
|
+
# Cap length
|
|
47
|
+
if len(normalized) > max_len:
|
|
48
|
+
normalized = normalized[:max_len]
|
|
49
|
+
return normalized
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def round_bbox(bbox: dict[str, float], precision: int = 2) -> dict[str, int]:
|
|
53
|
+
"""
|
|
54
|
+
Round bbox coordinates to reduce noise.
|
|
55
|
+
|
|
56
|
+
Snaps coordinates to grid of `precision` pixels to ignore
|
|
57
|
+
sub-pixel rendering differences.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
bbox: Bounding box with x, y, width, height
|
|
61
|
+
precision: Grid size in pixels (default: 2)
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Rounded bbox with integer coordinates
|
|
65
|
+
|
|
66
|
+
Examples:
|
|
67
|
+
>>> round_bbox({"x": 101, "y": 203, "width": 50, "height": 25})
|
|
68
|
+
{'x': 100, 'y': 202, 'width': 50, 'height': 24}
|
|
69
|
+
"""
|
|
70
|
+
return {
|
|
71
|
+
"x": round(bbox.get("x", 0) / precision) * precision,
|
|
72
|
+
"y": round(bbox.get("y", 0) / precision) * precision,
|
|
73
|
+
"width": round(bbox.get("width", 0) / precision) * precision,
|
|
74
|
+
"height": round(bbox.get("height", 0) / precision) * precision,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def bbox_equal(bbox1: dict[str, Any], bbox2: dict[str, Any], threshold: float = 5.0) -> bool:
|
|
79
|
+
"""
|
|
80
|
+
Check if two bboxes are equal within a threshold.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
bbox1: First bounding box
|
|
84
|
+
bbox2: Second bounding box
|
|
85
|
+
threshold: Maximum allowed difference in pixels (default: 5.0)
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
True if all bbox properties differ by less than threshold
|
|
89
|
+
|
|
90
|
+
Examples:
|
|
91
|
+
>>> bbox_equal({"x": 100, "y": 200, "width": 50, "height": 25},
|
|
92
|
+
... {"x": 102, "y": 200, "width": 50, "height": 25})
|
|
93
|
+
True # 2px difference is below 5px threshold
|
|
94
|
+
"""
|
|
95
|
+
return (
|
|
96
|
+
abs(bbox1.get("x", 0) - bbox2.get("x", 0)) <= threshold
|
|
97
|
+
and abs(bbox1.get("y", 0) - bbox2.get("y", 0)) <= threshold
|
|
98
|
+
and abs(bbox1.get("width", 0) - bbox2.get("width", 0)) <= threshold
|
|
99
|
+
and abs(bbox1.get("height", 0) - bbox2.get("height", 0)) <= threshold
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def bbox_changed(bbox1: dict[str, Any], bbox2: dict[str, Any], threshold: float = 5.0) -> bool:
|
|
104
|
+
"""
|
|
105
|
+
Check if two bboxes differ beyond the threshold.
|
|
106
|
+
|
|
107
|
+
This is the inverse of bbox_equal, provided for semantic clarity
|
|
108
|
+
in diff detection code.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
bbox1: First bounding box
|
|
112
|
+
bbox2: Second bounding box
|
|
113
|
+
threshold: Maximum allowed difference in pixels (default: 5.0)
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
True if any bbox property differs by more than threshold
|
|
117
|
+
"""
|
|
118
|
+
return not bbox_equal(bbox1, bbox2, threshold)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def canonicalize_element(elem: dict[str, Any]) -> dict[str, Any]:
|
|
122
|
+
"""
|
|
123
|
+
Create canonical representation of an element for comparison/hashing.
|
|
124
|
+
|
|
125
|
+
Extracts and normalizes the fields that matter for identity:
|
|
126
|
+
- id, role, normalized text, rounded bbox
|
|
127
|
+
- is_primary, is_clickable from visual_cues
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
elem: Raw element dictionary
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Canonical element dictionary with normalized fields
|
|
134
|
+
|
|
135
|
+
Examples:
|
|
136
|
+
>>> canonicalize_element({
|
|
137
|
+
... "id": 1,
|
|
138
|
+
... "role": "button",
|
|
139
|
+
... "text": " Click Me ",
|
|
140
|
+
... "bbox": {"x": 101, "y": 200, "width": 50, "height": 25},
|
|
141
|
+
... "visual_cues": {"is_primary": True, "is_clickable": True}
|
|
142
|
+
... })
|
|
143
|
+
{'id': 1, 'role': 'button', 'text_norm': 'click me', 'bbox': {'x': 100, 'y': 200, 'width': 50, 'height': 24}, 'is_primary': True, 'is_clickable': True}
|
|
144
|
+
"""
|
|
145
|
+
# Extract is_primary and is_clickable from visual_cues if present
|
|
146
|
+
visual_cues = elem.get("visual_cues", {})
|
|
147
|
+
is_primary = (
|
|
148
|
+
visual_cues.get("is_primary", False)
|
|
149
|
+
if isinstance(visual_cues, dict)
|
|
150
|
+
else elem.get("is_primary", False)
|
|
151
|
+
)
|
|
152
|
+
is_clickable = (
|
|
153
|
+
visual_cues.get("is_clickable", False)
|
|
154
|
+
if isinstance(visual_cues, dict)
|
|
155
|
+
else elem.get("is_clickable", False)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
"id": elem.get("id"),
|
|
160
|
+
"role": elem.get("role", ""),
|
|
161
|
+
"text_norm": normalize_text(elem.get("text")),
|
|
162
|
+
"bbox": round_bbox(elem.get("bbox", {"x": 0, "y": 0, "width": 0, "height": 0})),
|
|
163
|
+
"is_primary": is_primary,
|
|
164
|
+
"is_clickable": is_clickable,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def content_equal(elem1: dict[str, Any], elem2: dict[str, Any]) -> bool:
|
|
169
|
+
"""
|
|
170
|
+
Check if two elements have equal content (ignoring position).
|
|
171
|
+
|
|
172
|
+
Compares normalized text, role, and visual cues.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
elem1: First element (raw or canonical)
|
|
176
|
+
elem2: Second element (raw or canonical)
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
True if content is equal after normalization
|
|
180
|
+
"""
|
|
181
|
+
# Normalize both elements
|
|
182
|
+
c1 = canonicalize_element(elem1)
|
|
183
|
+
c2 = canonicalize_element(elem2)
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
c1["role"] == c2["role"]
|
|
187
|
+
and c1["text_norm"] == c2["text_norm"]
|
|
188
|
+
and c1["is_primary"] == c2["is_primary"]
|
|
189
|
+
and c1["is_clickable"] == c2["is_clickable"]
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def content_changed(elem1: dict[str, Any], elem2: dict[str, Any]) -> bool:
|
|
194
|
+
"""
|
|
195
|
+
Check if two elements have different content (ignoring position).
|
|
196
|
+
|
|
197
|
+
This is the inverse of content_equal, provided for semantic clarity
|
|
198
|
+
in diff detection code.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
elem1: First element
|
|
202
|
+
elem2: Second element
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
True if content differs after normalization
|
|
206
|
+
"""
|
|
207
|
+
return not content_equal(elem1, elem2)
|
sentience/cloud_tracing.py
CHANGED
|
@@ -16,6 +16,7 @@ from typing import Any, Optional, Protocol, Union
|
|
|
16
16
|
|
|
17
17
|
import requests
|
|
18
18
|
|
|
19
|
+
from sentience.constants import SENTIENCE_API_URL
|
|
19
20
|
from sentience.models import TraceStats
|
|
20
21
|
from sentience.trace_file_manager import TraceFileManager
|
|
21
22
|
from sentience.tracing import TraceSink
|
|
@@ -93,7 +94,7 @@ class CloudTraceSink(TraceSink):
|
|
|
93
94
|
self.upload_url = upload_url
|
|
94
95
|
self.run_id = run_id
|
|
95
96
|
self.api_key = api_key
|
|
96
|
-
self.api_url = api_url or
|
|
97
|
+
self.api_url = api_url or SENTIENCE_API_URL
|
|
97
98
|
self.logger = logger
|
|
98
99
|
|
|
99
100
|
# Use persistent cache directory instead of temp file
|
|
@@ -147,40 +148,80 @@ class CloudTraceSink(TraceSink):
|
|
|
147
148
|
|
|
148
149
|
self._closed = True
|
|
149
150
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
if not blocking:
|
|
152
|
+
# Fire-and-forget background finalize+upload.
|
|
153
|
+
#
|
|
154
|
+
# IMPORTANT: for truly non-blocking close, we avoid synchronous work here
|
|
155
|
+
# (flush/fsync/index generation). That work happens in the background thread.
|
|
156
|
+
thread = threading.Thread(
|
|
157
|
+
target=self._close_and_upload_background,
|
|
158
|
+
args=(on_progress,),
|
|
159
|
+
daemon=True,
|
|
160
|
+
)
|
|
161
|
+
thread.start()
|
|
162
|
+
return # Return immediately
|
|
163
|
+
|
|
164
|
+
# Blocking mode: finalize trace file and upload now.
|
|
165
|
+
if not self._finalize_trace_file_for_upload():
|
|
166
|
+
return
|
|
167
|
+
self._do_upload(on_progress)
|
|
168
|
+
|
|
169
|
+
def _finalize_trace_file_for_upload(self) -> bool:
|
|
170
|
+
"""
|
|
171
|
+
Finalize the local trace file so it is ready for upload.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
True if there is data to upload, False if the trace is empty/missing.
|
|
175
|
+
"""
|
|
176
|
+
# Flush and sync file to disk before closing to ensure all data is written.
|
|
177
|
+
# This can be slow on CI file systems; in non-blocking close we do this in background.
|
|
178
|
+
try:
|
|
179
|
+
self._trace_file.flush()
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
153
182
|
try:
|
|
154
|
-
# Force OS to write buffered data to disk
|
|
155
183
|
os.fsync(self._trace_file.fileno())
|
|
156
184
|
except (OSError, AttributeError):
|
|
157
|
-
# Some file handles don't support fsync
|
|
158
|
-
|
|
185
|
+
# Some file handles don't support fsync; flush is usually sufficient.
|
|
186
|
+
pass
|
|
187
|
+
try:
|
|
188
|
+
self._trace_file.close()
|
|
189
|
+
except Exception:
|
|
159
190
|
pass
|
|
160
|
-
self._trace_file.close()
|
|
161
191
|
|
|
162
192
|
# Ensure file exists and has content before proceeding
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
193
|
+
try:
|
|
194
|
+
if not self._path.exists() or self._path.stat().st_size == 0:
|
|
195
|
+
if self.logger:
|
|
196
|
+
self.logger.warning("No trace events to upload (file is empty or missing)")
|
|
197
|
+
return False
|
|
198
|
+
except Exception:
|
|
199
|
+
# If we can't stat, don't attempt upload
|
|
200
|
+
return False
|
|
168
201
|
|
|
169
202
|
# Generate index after closing file
|
|
170
203
|
self._generate_index()
|
|
204
|
+
return True
|
|
171
205
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
daemon=True,
|
|
178
|
-
)
|
|
179
|
-
thread.start()
|
|
180
|
-
return # Return immediately
|
|
206
|
+
def _close_and_upload_background(
|
|
207
|
+
self, on_progress: Callable[[int, int], None] | None = None
|
|
208
|
+
) -> None:
|
|
209
|
+
"""
|
|
210
|
+
Background worker for non-blocking close.
|
|
181
211
|
|
|
182
|
-
|
|
183
|
-
|
|
212
|
+
Performs file finalization + index generation + upload.
|
|
213
|
+
"""
|
|
214
|
+
try:
|
|
215
|
+
if not self._finalize_trace_file_for_upload():
|
|
216
|
+
return
|
|
217
|
+
self._do_upload(on_progress)
|
|
218
|
+
except Exception as e:
|
|
219
|
+
# Non-fatal: preserve trace locally
|
|
220
|
+
self._upload_successful = False
|
|
221
|
+
print(f"❌ [Sentience] Error uploading trace (background): {e}")
|
|
222
|
+
print(f" Local trace preserved at: {self._path}")
|
|
223
|
+
if self.logger:
|
|
224
|
+
self.logger.error(f"Error uploading trace (background): {e}")
|
|
184
225
|
|
|
185
226
|
def _do_upload(self, on_progress: Callable[[int, int], None] | None = None) -> None:
|
|
186
227
|
"""
|
sentience/constants.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Human-like cursor movement policy + metadata.
|
|
3
|
+
|
|
4
|
+
This is intentionally SDK-local (no snapshot schema changes). It is used by actions to:
|
|
5
|
+
- generate more realistic mouse movement (multiple moves with easing, optional overshoot/jitter)
|
|
6
|
+
- emit trace/debug metadata describing the movement path
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import random
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class CursorPolicy:
|
|
18
|
+
"""
|
|
19
|
+
Policy for cursor movement.
|
|
20
|
+
|
|
21
|
+
- mode="instant": current behavior (single click without multi-step motion)
|
|
22
|
+
- mode="human": move with a curved path + optional jitter/overshoot
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
mode: str = "instant" # "instant" | "human"
|
|
26
|
+
|
|
27
|
+
# Motion shaping (human mode)
|
|
28
|
+
steps: int | None = None
|
|
29
|
+
duration_ms: int | None = None
|
|
30
|
+
jitter_px: float = 1.0
|
|
31
|
+
overshoot_px: float = 6.0
|
|
32
|
+
pause_before_click_ms: int = 20
|
|
33
|
+
|
|
34
|
+
# Determinism hook for tests/repro
|
|
35
|
+
seed: int | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _clamp(v: float, lo: float, hi: float) -> float:
|
|
39
|
+
return max(lo, min(hi, v))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _ease_in_out(t: float) -> float:
|
|
43
|
+
# Smoothstep-ish easing
|
|
44
|
+
return t * t * (3 - 2 * t)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _bezier(
|
|
48
|
+
p0: tuple[float, float],
|
|
49
|
+
p1: tuple[float, float],
|
|
50
|
+
p2: tuple[float, float],
|
|
51
|
+
p3: tuple[float, float],
|
|
52
|
+
t: float,
|
|
53
|
+
) -> tuple[float, float]:
|
|
54
|
+
u = 1.0 - t
|
|
55
|
+
tt = t * t
|
|
56
|
+
uu = u * u
|
|
57
|
+
uuu = uu * u
|
|
58
|
+
ttt = tt * t
|
|
59
|
+
x = uuu * p0[0] + 3 * uu * t * p1[0] + 3 * u * tt * p2[0] + ttt * p3[0]
|
|
60
|
+
y = uuu * p0[1] + 3 * uu * t * p1[1] + 3 * u * tt * p2[1] + ttt * p3[1]
|
|
61
|
+
return (x, y)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_human_cursor_path(
|
|
65
|
+
*,
|
|
66
|
+
start: tuple[float, float],
|
|
67
|
+
target: tuple[float, float],
|
|
68
|
+
policy: CursorPolicy,
|
|
69
|
+
) -> dict:
|
|
70
|
+
"""
|
|
71
|
+
Build a human-like cursor path and metadata.
|
|
72
|
+
|
|
73
|
+
Returns a dict suitable for attaching to ActionResult/trace payloads:
|
|
74
|
+
{
|
|
75
|
+
"mode": "human",
|
|
76
|
+
"from": {"x":..., "y":...},
|
|
77
|
+
"to": {"x":..., "y":...},
|
|
78
|
+
"steps": ...,
|
|
79
|
+
"duration_ms": ...,
|
|
80
|
+
"pause_before_click_ms": ...,
|
|
81
|
+
"jitter_px": ...,
|
|
82
|
+
"overshoot_px": ...,
|
|
83
|
+
"path": [{"x":..., "y":..., "t":...}, ...]
|
|
84
|
+
}
|
|
85
|
+
"""
|
|
86
|
+
rng = random.Random(policy.seed)
|
|
87
|
+
|
|
88
|
+
x0, y0 = start
|
|
89
|
+
x1, y1 = target
|
|
90
|
+
dx = x1 - x0
|
|
91
|
+
dy = y1 - y0
|
|
92
|
+
dist = math.hypot(dx, dy)
|
|
93
|
+
|
|
94
|
+
# Defaults based on distance (bounded)
|
|
95
|
+
steps = int(policy.steps if policy.steps is not None else _clamp(10 + dist / 25.0, 12, 40))
|
|
96
|
+
duration_ms = int(
|
|
97
|
+
policy.duration_ms if policy.duration_ms is not None else _clamp(120 + dist * 0.9, 120, 700)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Control points: offset roughly perpendicular to travel direction
|
|
101
|
+
if dist < 1e-6:
|
|
102
|
+
dist = 1.0
|
|
103
|
+
ux, uy = dx / dist, dy / dist
|
|
104
|
+
px, py = -uy, ux
|
|
105
|
+
curve_mag = _clamp(dist / 3.5, 10.0, 140.0)
|
|
106
|
+
curve_mag *= rng.uniform(0.5, 1.2)
|
|
107
|
+
|
|
108
|
+
c1 = (x0 + dx * 0.25 + px * curve_mag, y0 + dy * 0.25 + py * curve_mag)
|
|
109
|
+
c2 = (x0 + dx * 0.75 - px * curve_mag, y0 + dy * 0.75 - py * curve_mag)
|
|
110
|
+
|
|
111
|
+
overshoot = float(policy.overshoot_px or 0.0)
|
|
112
|
+
overshoot_point = (x1 + ux * overshoot, y1 + uy * overshoot) if overshoot > 0 else (x1, y1)
|
|
113
|
+
|
|
114
|
+
pts: list[dict] = []
|
|
115
|
+
for i in range(steps):
|
|
116
|
+
t_raw = 0.0 if steps <= 1 else i / (steps - 1)
|
|
117
|
+
t = _ease_in_out(t_raw)
|
|
118
|
+
bx, by = _bezier((x0, y0), c1, c2, overshoot_point, t)
|
|
119
|
+
|
|
120
|
+
# Small jitter, decaying near target
|
|
121
|
+
jitter_scale = float(policy.jitter_px) * (1.0 - t_raw) * 0.9
|
|
122
|
+
jx = rng.uniform(-jitter_scale, jitter_scale)
|
|
123
|
+
jy = rng.uniform(-jitter_scale, jitter_scale)
|
|
124
|
+
|
|
125
|
+
pts.append({"x": bx + jx, "y": by + jy, "t": round(t_raw, 4)})
|
|
126
|
+
|
|
127
|
+
# If we overshot, add a small correction segment back to target.
|
|
128
|
+
if overshoot > 0:
|
|
129
|
+
pts.append({"x": x1, "y": y1, "t": 1.0})
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
"mode": "human",
|
|
133
|
+
"from": {"x": x0, "y": y0},
|
|
134
|
+
"to": {"x": x1, "y": y1},
|
|
135
|
+
"steps": steps,
|
|
136
|
+
"duration_ms": duration_ms,
|
|
137
|
+
"pause_before_click_ms": int(policy.pause_before_click_ms),
|
|
138
|
+
"jitter_px": float(policy.jitter_px),
|
|
139
|
+
"overshoot_px": overshoot,
|
|
140
|
+
# Keep path bounded for trace size
|
|
141
|
+
"path": pts[:64],
|
|
142
|
+
}
|
sentience/extension/content.js
CHANGED
|
@@ -114,6 +114,41 @@
|
|
|
114
114
|
|
|
115
115
|
case "SENTIENCE_CLEAR_OVERLAY":
|
|
116
116
|
removeOverlay();
|
|
117
|
+
break;
|
|
118
|
+
|
|
119
|
+
case "SENTIENCE_SHOW_GRID_OVERLAY":
|
|
120
|
+
!function(data) {
|
|
121
|
+
const {grids: grids, targetGridId: targetGridId} = data;
|
|
122
|
+
if (!grids || !Array.isArray(grids)) return;
|
|
123
|
+
removeOverlay();
|
|
124
|
+
const host = document.createElement("div");
|
|
125
|
+
host.id = OVERLAY_HOST_ID, host.style.cssText = "\n position: fixed !important;\n top: 0 !important;\n left: 0 !important;\n width: 100vw !important;\n height: 100vh !important;\n pointer-events: none !important;\n z-index: 2147483647 !important;\n margin: 0 !important;\n padding: 0 !important;\n ",
|
|
126
|
+
document.body.appendChild(host);
|
|
127
|
+
const shadow = host.attachShadow({
|
|
128
|
+
mode: "closed"
|
|
129
|
+
});
|
|
130
|
+
grids.forEach(grid => {
|
|
131
|
+
const bbox = grid.bbox;
|
|
132
|
+
if (!bbox) return;
|
|
133
|
+
const isTarget = grid.grid_id === targetGridId, isDominant = !0 === grid.is_dominant;
|
|
134
|
+
let color = "#9B59B6";
|
|
135
|
+
isTarget ? color = "#FF0000" : isDominant && (color = "#FF8C00");
|
|
136
|
+
const borderStyle = isTarget ? "solid" : "dashed", borderWidth = isTarget ? 3 : isDominant ? 2.5 : 2, opacity = isTarget ? 1 : isDominant ? .9 : .8, fillOpacity = .1 * opacity, hexOpacity = Math.round(255 * fillOpacity).toString(16).padStart(2, "0"), box = document.createElement("div");
|
|
137
|
+
box.style.cssText = `\n position: absolute;\n left: ${bbox.x}px;\n top: ${bbox.y}px;\n width: ${bbox.width}px;\n height: ${bbox.height}px;\n border: ${borderWidth}px ${borderStyle} ${color};\n background-color: ${color}${hexOpacity};\n box-sizing: border-box;\n opacity: ${opacity};\n pointer-events: none;\n `;
|
|
138
|
+
let labelText = grid.label ? `Grid ${grid.grid_id}: ${grid.label}` : `Grid ${grid.grid_id}`;
|
|
139
|
+
grid.is_dominant && (labelText = `⭐ ${labelText} (dominant)`);
|
|
140
|
+
const badge = document.createElement("span");
|
|
141
|
+
if (badge.textContent = labelText, badge.style.cssText = `\n position: absolute;\n top: -18px;\n left: 0;\n background: ${color};\n color: white;\n font-size: 11px;\n font-weight: bold;\n padding: 2px 6px;\n font-family: Arial, sans-serif;\n border-radius: 3px;\n opacity: 0.95;\n white-space: nowrap;\n pointer-events: none;\n `,
|
|
142
|
+
box.appendChild(badge), isTarget) {
|
|
143
|
+
const targetIndicator = document.createElement("span");
|
|
144
|
+
targetIndicator.textContent = "🎯", targetIndicator.style.cssText = "\n position: absolute;\n top: -18px;\n right: 0;\n font-size: 16px;\n pointer-events: none;\n ",
|
|
145
|
+
box.appendChild(targetIndicator);
|
|
146
|
+
}
|
|
147
|
+
shadow.appendChild(box);
|
|
148
|
+
}), overlayTimeout = setTimeout(() => {
|
|
149
|
+
removeOverlay();
|
|
150
|
+
}, 5e3);
|
|
151
|
+
}(event.data);
|
|
117
152
|
}
|
|
118
153
|
});
|
|
119
154
|
const OVERLAY_HOST_ID = "sentience-overlay-host";
|