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.

Files changed (64) hide show
  1. sentience/__init__.py +107 -2
  2. sentience/_extension_loader.py +156 -1
  3. sentience/action_executor.py +2 -0
  4. sentience/actions.py +354 -9
  5. sentience/agent.py +4 -0
  6. sentience/agent_runtime.py +840 -0
  7. sentience/asserts/__init__.py +70 -0
  8. sentience/asserts/expect.py +621 -0
  9. sentience/asserts/query.py +383 -0
  10. sentience/async_api.py +8 -1
  11. sentience/backends/__init__.py +137 -0
  12. sentience/backends/actions.py +372 -0
  13. sentience/backends/browser_use_adapter.py +241 -0
  14. sentience/backends/cdp_backend.py +393 -0
  15. sentience/backends/exceptions.py +211 -0
  16. sentience/backends/playwright_backend.py +194 -0
  17. sentience/backends/protocol.py +216 -0
  18. sentience/backends/sentience_context.py +469 -0
  19. sentience/backends/snapshot.py +483 -0
  20. sentience/browser.py +230 -74
  21. sentience/canonicalization.py +207 -0
  22. sentience/cloud_tracing.py +65 -24
  23. sentience/constants.py +6 -0
  24. sentience/cursor_policy.py +142 -0
  25. sentience/extension/content.js +35 -0
  26. sentience/extension/injected_api.js +310 -15
  27. sentience/extension/manifest.json +1 -1
  28. sentience/extension/pkg/sentience_core.d.ts +22 -22
  29. sentience/extension/pkg/sentience_core.js +192 -144
  30. sentience/extension/pkg/sentience_core_bg.wasm +0 -0
  31. sentience/extension/release.json +29 -29
  32. sentience/failure_artifacts.py +241 -0
  33. sentience/integrations/__init__.py +6 -0
  34. sentience/integrations/langchain/__init__.py +12 -0
  35. sentience/integrations/langchain/context.py +18 -0
  36. sentience/integrations/langchain/core.py +326 -0
  37. sentience/integrations/langchain/tools.py +180 -0
  38. sentience/integrations/models.py +46 -0
  39. sentience/integrations/pydanticai/__init__.py +15 -0
  40. sentience/integrations/pydanticai/deps.py +20 -0
  41. sentience/integrations/pydanticai/toolset.py +468 -0
  42. sentience/llm_provider.py +695 -18
  43. sentience/models.py +536 -3
  44. sentience/ordinal.py +280 -0
  45. sentience/query.py +66 -4
  46. sentience/schemas/trace_v1.json +27 -1
  47. sentience/snapshot.py +384 -93
  48. sentience/snapshot_diff.py +39 -54
  49. sentience/text_search.py +1 -0
  50. sentience/trace_event_builder.py +20 -1
  51. sentience/trace_indexing/indexer.py +3 -49
  52. sentience/tracer_factory.py +1 -3
  53. sentience/verification.py +618 -0
  54. sentience/visual_agent.py +3 -1
  55. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/METADATA +198 -40
  56. sentienceapi-0.98.0.dist-info/RECORD +92 -0
  57. sentience/utils.py +0 -296
  58. sentienceapi-0.92.2.dist-info/RECORD +0 -65
  59. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/WHEEL +0 -0
  60. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/entry_points.txt +0 -0
  61. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE +0 -0
  62. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-APACHE +0 -0
  63. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-MIT +0 -0
  64. {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)
@@ -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 "https://api.sentienceapi.com"
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
- # Flush and sync file to disk before closing to ensure all data is written
151
- # This is critical on CI systems where file system operations may be slower
152
- self._trace_file.flush()
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 (e.g., StringIO in tests)
158
- # This is fine - flush() is usually sufficient
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
- if not self._path.exists() or self._path.stat().st_size == 0:
164
- # No events were emitted, nothing to upload
165
- if self.logger:
166
- self.logger.warning("No trace events to upload (file is empty or missing)")
167
- return
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
- if not blocking:
173
- # Fire-and-forget background upload
174
- thread = threading.Thread(
175
- target=self._do_upload,
176
- args=(on_progress,),
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
- # Blocking mode
183
- self._do_upload(on_progress)
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,6 @@
1
+ """
2
+ Sentience SDK constants.
3
+ """
4
+
5
+ # Sentience API endpoint
6
+ SENTIENCE_API_URL = "https://api.sentienceapi.com"
@@ -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
+ }
@@ -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";