browser-control-and-automation-cli 0.1.1.dev3__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 (104) hide show
  1. browser_cli/__init__.py +7 -0
  2. browser_cli/actions/__init__.py +5 -0
  3. browser_cli/actions/cli_specs.py +868 -0
  4. browser_cli/agent_scope/__init__.py +14 -0
  5. browser_cli/automation/__init__.py +11 -0
  6. browser_cli/automation/api/__init__.py +5 -0
  7. browser_cli/automation/api/server.py +321 -0
  8. browser_cli/automation/hooks.py +42 -0
  9. browser_cli/automation/loader.py +134 -0
  10. browser_cli/automation/models.py +127 -0
  11. browser_cli/automation/persistence/__init__.py +5 -0
  12. browser_cli/automation/persistence/store.py +682 -0
  13. browser_cli/automation/projections.py +279 -0
  14. browser_cli/automation/publisher.py +153 -0
  15. browser_cli/automation/scheduler/__init__.py +5 -0
  16. browser_cli/automation/scheduler/schedule.py +113 -0
  17. browser_cli/automation/service/__init__.py +8 -0
  18. browser_cli/automation/service/__main__.py +64 -0
  19. browser_cli/automation/service/client.py +232 -0
  20. browser_cli/automation/service/runtime.py +388 -0
  21. browser_cli/automation/toml.py +35 -0
  22. browser_cli/automation/web/__init__.py +5 -0
  23. browser_cli/automation/web/assets.py +377 -0
  24. browser_cli/browser/__init__.py +1 -0
  25. browser_cli/browser/models.py +44 -0
  26. browser_cli/browser/network_capture.py +392 -0
  27. browser_cli/browser/service.py +1519 -0
  28. browser_cli/browser/session.py +158 -0
  29. browser_cli/browser/snapshot.py +35 -0
  30. browser_cli/browser/stealth.py +450 -0
  31. browser_cli/cli/__init__.py +1 -0
  32. browser_cli/cli/error_hints.py +26 -0
  33. browser_cli/cli/main.py +298 -0
  34. browser_cli/commands/__init__.py +1 -0
  35. browser_cli/commands/action.py +25 -0
  36. browser_cli/commands/automation.py +304 -0
  37. browser_cli/commands/doctor.py +336 -0
  38. browser_cli/commands/install_skills.py +138 -0
  39. browser_cli/commands/paths.py +31 -0
  40. browser_cli/commands/read.py +33 -0
  41. browser_cli/commands/reload.py +51 -0
  42. browser_cli/commands/status.py +444 -0
  43. browser_cli/commands/task.py +71 -0
  44. browser_cli/constants.py +97 -0
  45. browser_cli/daemon/__init__.py +1 -0
  46. browser_cli/daemon/__main__.py +34 -0
  47. browser_cli/daemon/app.py +941 -0
  48. browser_cli/daemon/browser_service.py +998 -0
  49. browser_cli/daemon/client.py +280 -0
  50. browser_cli/daemon/models.py +63 -0
  51. browser_cli/daemon/runtime_presentation.py +167 -0
  52. browser_cli/daemon/server.py +103 -0
  53. browser_cli/daemon/state.py +15 -0
  54. browser_cli/daemon/transport.py +77 -0
  55. browser_cli/drivers/__init__.py +6 -0
  56. browser_cli/drivers/_extension/__init__.py +1 -0
  57. browser_cli/drivers/_extension/artifact_actions.py +154 -0
  58. browser_cli/drivers/_extension/dialog_actions.py +35 -0
  59. browser_cli/drivers/_extension/input_actions.py +63 -0
  60. browser_cli/drivers/_extension/locator_actions.py +100 -0
  61. browser_cli/drivers/_extension/observe_actions.py +285 -0
  62. browser_cli/drivers/_extension/page_actions.py +210 -0
  63. browser_cli/drivers/_extension/state_actions.py +134 -0
  64. browser_cli/drivers/base.py +391 -0
  65. browser_cli/drivers/extension_driver.py +33 -0
  66. browser_cli/drivers/models.py +21 -0
  67. browser_cli/drivers/playwright_driver.py +499 -0
  68. browser_cli/error_codes.py +27 -0
  69. browser_cli/errors.py +146 -0
  70. browser_cli/exit_codes.py +9 -0
  71. browser_cli/extension/__init__.py +32 -0
  72. browser_cli/extension/protocol.py +222 -0
  73. browser_cli/extension/session.py +393 -0
  74. browser_cli/network.py +147 -0
  75. browser_cli/outputs/__init__.py +1 -0
  76. browser_cli/outputs/json.py +10 -0
  77. browser_cli/outputs/render.py +7 -0
  78. browser_cli/packaged_skills/__init__.py +1 -0
  79. browser_cli/packaged_skills/browser-cli-converge/SKILL.md +62 -0
  80. browser_cli/packaged_skills/browser-cli-delivery/SKILL.md +61 -0
  81. browser_cli/packaged_skills/browser-cli-explore/SKILL.md +77 -0
  82. browser_cli/profiles/__init__.py +1 -0
  83. browser_cli/profiles/discovery.py +185 -0
  84. browser_cli/refs/__init__.py +17 -0
  85. browser_cli/refs/generator.py +386 -0
  86. browser_cli/refs/models.py +127 -0
  87. browser_cli/refs/registry.py +51 -0
  88. browser_cli/refs/resolver.py +214 -0
  89. browser_cli/tabs/__init__.py +5 -0
  90. browser_cli/tabs/registry.py +283 -0
  91. browser_cli/task_runtime/__init__.py +31 -0
  92. browser_cli/task_runtime/client.py +107 -0
  93. browser_cli/task_runtime/entrypoint.py +110 -0
  94. browser_cli/task_runtime/errors.py +17 -0
  95. browser_cli/task_runtime/flow.py +171 -0
  96. browser_cli/task_runtime/models.py +97 -0
  97. browser_cli/task_runtime/read.py +71 -0
  98. browser_cli/task_runtime/templates.py +58 -0
  99. browser_control_and_automation_cli-0.1.1.dev3.dist-info/METADATA +340 -0
  100. browser_control_and_automation_cli-0.1.1.dev3.dist-info/RECORD +104 -0
  101. browser_control_and_automation_cli-0.1.1.dev3.dist-info/WHEEL +5 -0
  102. browser_control_and_automation_cli-0.1.1.dev3.dist-info/entry_points.txt +2 -0
  103. browser_control_and_automation_cli-0.1.1.dev3.dist-info/licenses/LICENSE +21 -0
  104. browser_control_and_automation_cli-0.1.1.dev3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,386 @@
1
+ """Semantic snapshot generation adapted from bridgic-browser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ import time
8
+ import uuid
9
+ from dataclasses import dataclass, replace
10
+ from typing import Any
11
+
12
+ from browser_cli.refs.models import RefData, SemanticSnapshot, SnapshotMetadata
13
+ from browser_cli.refs.resolver import SemanticRefResolver
14
+
15
+
16
+ @dataclass(slots=True, frozen=True)
17
+ class SnapshotOptions:
18
+ interactive: bool = False
19
+ full_page: bool = True
20
+
21
+
22
+ class SemanticSnapshotGenerator:
23
+ CONTENT_ROLES = {
24
+ "article",
25
+ "cell",
26
+ "columnheader",
27
+ "heading",
28
+ "main",
29
+ "navigation",
30
+ "region",
31
+ "rowheader",
32
+ }
33
+ ALWAYS_REF_ROLES = {
34
+ "cell",
35
+ "columnheader",
36
+ "gridcell",
37
+ "listitem",
38
+ "option",
39
+ "row",
40
+ "rowheader",
41
+ }
42
+ LANDMARK_ROLES = {
43
+ "banner",
44
+ "contentinfo",
45
+ "complementary",
46
+ "form",
47
+ "main",
48
+ "navigation",
49
+ "region",
50
+ "search",
51
+ }
52
+ SEMANTIC_ROLES = {
53
+ "alert",
54
+ "blockquote",
55
+ "caption",
56
+ "code",
57
+ "definition",
58
+ "document",
59
+ "emphasis",
60
+ "feed",
61
+ "figure",
62
+ "img",
63
+ "insertion",
64
+ "log",
65
+ "marquee",
66
+ "math",
67
+ "meter",
68
+ "note",
69
+ "paragraph",
70
+ "status",
71
+ "strong",
72
+ "subscript",
73
+ "superscript",
74
+ "term",
75
+ "text",
76
+ "time",
77
+ "timer",
78
+ "tooltip",
79
+ }
80
+ STRUCTURAL_ROLES = {"directory", "list", "row", "rowgroup", "table", "toolbar"}
81
+ STRUCTURAL_NOISE_ROLES = SemanticRefResolver.STRUCTURAL_NOISE_ROLES
82
+ TEXT_LEAF_ROLES = SemanticRefResolver.TEXT_LEAF_ROLES
83
+ INTERACTIVE_ROLES = SemanticRefResolver.INTERACTIVE_ROLES
84
+ _REF_NAMESPACE = "browser-cli-semantic-v1"
85
+ _YAML_QUOTE_PATTERN = re.compile(r"^(\s*-\s*)'(.+)'(:{0,1})\s*$")
86
+ _LINE_PATTERN = re.compile(
87
+ r"^(\s*-\s*)"
88
+ r"([A-Za-z0-9_/-]+)"
89
+ r'(?:\s+"((?:[^"\\]|\\.)*)")?'
90
+ r"(.*)$"
91
+ )
92
+ _REF_CLEAN_PATTERN = re.compile(r"\s*\[ref=[a-zA-Z0-9]+\]")
93
+ _REF_EXTRACT_PATTERN = re.compile(r"\[ref=([a-zA-Z0-9]+)\]")
94
+
95
+ async def page_snapshot_for_ai(self, page: Any) -> str:
96
+ page_impl = page._impl_obj
97
+ channel = page_impl._channel
98
+ result = await channel.send_return_as_dict(
99
+ "snapshotForAI",
100
+ page_impl._timeout_settings.timeout,
101
+ {"track": None, "timeout": 30000},
102
+ is_internal=True,
103
+ )
104
+ return str(result.get("full") or "")
105
+
106
+ @classmethod
107
+ def _strip_yaml_quotes(cls, line: str) -> str:
108
+ match = cls._YAML_QUOTE_PATTERN.match(line)
109
+ if not match:
110
+ return line
111
+ prefix, content, colon = match.groups()
112
+ content = content.replace("''", "'")
113
+ return f"{prefix}{content}{colon}"
114
+
115
+ @classmethod
116
+ def _normalize_raw_snapshot(cls, raw: str) -> str:
117
+ return "\n".join(cls._strip_yaml_quotes(line) for line in raw.splitlines())
118
+
119
+ @staticmethod
120
+ def _indent_level(line: str) -> int:
121
+ return len(line) - len(line.lstrip(" "))
122
+
123
+ @classmethod
124
+ def _build_selector(
125
+ cls, role: str, name: str | None = None, text_content: str | None = None
126
+ ) -> str:
127
+ if name:
128
+ escaped_name = name.replace('"', '\\"')
129
+ if role in cls.TEXT_LEAF_ROLES:
130
+ return f'get_by_text("{escaped_name}", exact=True)'
131
+ return f'get_by_role("{role}", name="{escaped_name}", exact=True)'
132
+ if text_content:
133
+ escaped_text = text_content.replace('"', '\\"')
134
+ return f'get_by_text("{escaped_text}", exact=True)'
135
+ return f'get_by_role("{role}")'
136
+
137
+ @classmethod
138
+ def _compute_stable_ref(
139
+ cls,
140
+ role: str,
141
+ name: str | None,
142
+ frame_path: tuple[int, ...],
143
+ nth: int,
144
+ ) -> str:
145
+ frame_str = ",".join(str(item) for item in frame_path)
146
+ raw = f"{cls._REF_NAMESPACE}\x1f{role}\x1f{name or ''}\x1f{frame_str}\x1f{nth}"
147
+ return hashlib.sha256(raw.encode("utf-8")).digest()[:4].hex()
148
+
149
+ def _should_keep(
150
+ self, role_lower: str, name: str | None, suffix: str, *, interactive_only: bool
151
+ ) -> tuple[bool, bool]:
152
+ is_interactive = role_lower in self.INTERACTIVE_ROLES
153
+ has_cursor_pointer = "[cursor=pointer]" in suffix
154
+ has_aria_state = any(
155
+ token in suffix for token in ("[pressed", "[expanded", "[checked", "[selected")
156
+ )
157
+ is_disabled = "[disabled]" in suffix
158
+
159
+ should_keep = False
160
+ should_have_ref = False
161
+ if interactive_only:
162
+ if is_interactive or has_cursor_pointer or has_aria_state or is_disabled:
163
+ should_keep = True
164
+ should_have_ref = True
165
+ return should_keep, should_have_ref
166
+
167
+ if is_interactive or has_cursor_pointer or has_aria_state or is_disabled:
168
+ return True, True
169
+ if role_lower in self.ALWAYS_REF_ROLES:
170
+ return True, True
171
+ if (
172
+ role_lower
173
+ in self.CONTENT_ROLES
174
+ | self.LANDMARK_ROLES
175
+ | self.SEMANTIC_ROLES
176
+ | self.STRUCTURAL_ROLES
177
+ ):
178
+ return True, bool(name)
179
+ if role_lower in self.STRUCTURAL_NOISE_ROLES:
180
+ return bool(name), bool(name)
181
+ return True, bool(name)
182
+
183
+ async def get_snapshot(
184
+ self,
185
+ page: Any,
186
+ *,
187
+ page_id: str,
188
+ interactive: bool = False,
189
+ full_page: bool = True,
190
+ ) -> SemanticSnapshot:
191
+ raw_snapshot = await self.page_snapshot_for_ai(page)
192
+ return self.snapshot_from_raw_text(
193
+ raw_snapshot,
194
+ page_id=page_id,
195
+ captured_url=str(page.url),
196
+ interactive=interactive,
197
+ full_page=full_page,
198
+ )
199
+
200
+ def snapshot_from_raw_text(
201
+ self,
202
+ raw_snapshot: str,
203
+ *,
204
+ page_id: str,
205
+ captured_url: str,
206
+ interactive: bool = False,
207
+ full_page: bool = True,
208
+ captured_at: float | None = None,
209
+ ) -> SemanticSnapshot:
210
+ if not raw_snapshot:
211
+ snapshot_time = time.time() if captured_at is None else captured_at
212
+ metadata = SnapshotMetadata(
213
+ snapshot_id=f"snap_{uuid.uuid4().hex[:12]}",
214
+ page_id=page_id,
215
+ captured_url=captured_url,
216
+ captured_at=snapshot_time,
217
+ interactive=interactive,
218
+ full_page=full_page,
219
+ )
220
+ return SemanticSnapshot(tree="(empty)", refs={}, metadata=metadata)
221
+
222
+ normalized = self._normalize_raw_snapshot(raw_snapshot)
223
+ snapshot_id = f"snap_{uuid.uuid4().hex[:12]}"
224
+ snapshot_time = time.time() if captured_at is None else captured_at
225
+ metadata = SnapshotMetadata(
226
+ snapshot_id=snapshot_id,
227
+ page_id=page_id,
228
+ captured_url=captured_url,
229
+ captured_at=snapshot_time,
230
+ interactive=interactive,
231
+ full_page=full_page,
232
+ )
233
+
234
+ refs: dict[str, RefData] = {}
235
+ result: list[str] = []
236
+ depth_stack: list[tuple[int, bool, int, str | None]] = []
237
+ iframe_stack: list[tuple[int, tuple[int, ...]]] = []
238
+ iframe_local_counters: dict[tuple[int, ...], int] = {}
239
+ occurrence_counts: dict[tuple[str, str | None, tuple[int, ...]], int] = {}
240
+
241
+ def effective_depth(original_depth: int) -> int:
242
+ depth = 0
243
+ for stack_depth, kept, out_depth, _ in depth_stack:
244
+ if stack_depth < original_depth and kept:
245
+ depth = out_depth + 1
246
+ return depth
247
+
248
+ def nearest_parent_ref(original_depth: int) -> str | None:
249
+ for stack_depth, kept, _, ref in reversed(depth_stack):
250
+ if stack_depth < original_depth and kept and ref is not None:
251
+ return ref
252
+ return None
253
+
254
+ for line in normalized.splitlines():
255
+ if not line.strip():
256
+ continue
257
+
258
+ original_depth = self._indent_level(line)
259
+ while depth_stack and depth_stack[-1][0] >= original_depth:
260
+ depth_stack.pop()
261
+ while iframe_stack and iframe_stack[-1][0] >= original_depth:
262
+ iframe_stack.pop()
263
+
264
+ match = self._LINE_PATTERN.match(line)
265
+ if not match:
266
+ stripped = line.lstrip()
267
+ if interactive:
268
+ if stripped.startswith("- /") and any(kept for _, kept, _, _ in depth_stack):
269
+ result.append(f"{' ' * effective_depth(original_depth)}- {stripped[2:]}")
270
+ continue
271
+ if any(kept for _, kept, _, _ in depth_stack) or not depth_stack:
272
+ content = stripped[2:] if stripped.startswith("- ") else stripped
273
+ result.append(f"{' ' * effective_depth(original_depth)}- {content}")
274
+ continue
275
+
276
+ _, role, name, suffix = match.groups()
277
+ role_lower = role.lower()
278
+ if not name and suffix and ":" in suffix:
279
+ inline_label_match = re.search(r':\s*(?:"((?:[^"\\]|\\.)*)"|([^\n]+))\s*$', suffix)
280
+ if inline_label_match:
281
+ name = (
282
+ inline_label_match.group(1) or inline_label_match.group(2) or ""
283
+ ).strip() or None
284
+
285
+ if role.startswith("/"):
286
+ if any(kept for _, kept, _, _ in depth_stack) or not depth_stack:
287
+ result.append(f"{' ' * effective_depth(original_depth)}- {role}{suffix}")
288
+ continue
289
+
290
+ keep, have_ref = self._should_keep(
291
+ role_lower, name, suffix, interactive_only=interactive
292
+ )
293
+ current_out_depth = effective_depth(original_depth)
294
+ current_ref: str | None = None
295
+
296
+ if not keep:
297
+ depth_stack.append((original_depth, False, current_out_depth, None))
298
+ if role_lower == "iframe":
299
+ parent_path = iframe_stack[-1][1] if iframe_stack else ()
300
+ local_index = iframe_local_counters.get(parent_path, 0)
301
+ iframe_local_counters[parent_path] = local_index + 1
302
+ iframe_stack.append((original_depth, (*parent_path, local_index)))
303
+ continue
304
+
305
+ clean_suffix = self._REF_CLEAN_PATTERN.sub("", suffix).strip()
306
+ pw_ref_match = self._REF_EXTRACT_PATTERN.search(suffix)
307
+ playwright_ref = pw_ref_match.group(1) if pw_ref_match else None
308
+
309
+ enhanced = f"- {role}" if interactive else f"{' ' * current_out_depth}- {role}"
310
+ if name:
311
+ enhanced += f' "{name}"'
312
+
313
+ if have_ref:
314
+ frame_path = iframe_stack[-1][1] if iframe_stack else ()
315
+ occurrence_key = (role_lower, name, frame_path)
316
+ nth = occurrence_counts.get(occurrence_key, 0)
317
+ occurrence_counts[occurrence_key] = nth + 1
318
+ ref = self._compute_stable_ref(role_lower, name, frame_path, nth)
319
+ current_ref = ref
320
+ text_content = None
321
+ if clean_suffix and ":" in clean_suffix:
322
+ text_match = re.search(r':\s*"?([^"]+)"?\s*$', clean_suffix)
323
+ if text_match:
324
+ candidate = text_match.group(1).strip()
325
+ if candidate and candidate != name:
326
+ text_content = candidate
327
+ ref_data = RefData(
328
+ ref=ref,
329
+ role=role_lower,
330
+ name=name,
331
+ nth=nth,
332
+ text_content=text_content,
333
+ tag=None,
334
+ interactive=role_lower in self.INTERACTIVE_ROLES,
335
+ parent_ref=nearest_parent_ref(original_depth),
336
+ frame_path=frame_path,
337
+ playwright_ref=playwright_ref,
338
+ selector_recipe=self._build_selector(role_lower, name, text_content),
339
+ snapshot_id=snapshot_id,
340
+ page_id=page_id,
341
+ captured_url=captured_url,
342
+ captured_at=snapshot_time,
343
+ )
344
+ refs[ref] = ref_data
345
+ enhanced += f" [ref={ref}]"
346
+ if nth > 0 and name:
347
+ enhanced += f" [nth={nth}]"
348
+
349
+ depth_stack.append((original_depth, keep, current_out_depth, current_ref))
350
+ if role_lower == "iframe":
351
+ parent_path = iframe_stack[-1][1] if iframe_stack else ()
352
+ local_index = iframe_local_counters.get(parent_path, 0)
353
+ iframe_local_counters[parent_path] = local_index + 1
354
+ iframe_stack.append((original_depth, (*parent_path, local_index)))
355
+
356
+ if clean_suffix:
357
+ if name and ":" in clean_suffix:
358
+ colon_match = re.search(r":\s*(.+?)\s*$", clean_suffix)
359
+ if colon_match:
360
+ raw_text = colon_match.group(1).strip()
361
+ if raw_text.startswith('"') and raw_text.endswith('"'):
362
+ raw_text = raw_text[1:-1]
363
+ if raw_text == name:
364
+ clean_suffix = clean_suffix[: colon_match.start()].rstrip()
365
+ if clean_suffix == ":":
366
+ enhanced += ":"
367
+ elif clean_suffix.startswith(":"):
368
+ enhanced += clean_suffix
369
+ elif clean_suffix.endswith(":"):
370
+ enhanced += f" {clean_suffix[:-1]}:"
371
+ else:
372
+ enhanced += f" {clean_suffix}"
373
+
374
+ result.append(enhanced)
375
+
376
+ dedup_counts: dict[tuple[str, str | None, tuple[int, ...]], int] = {}
377
+ for item in refs.values():
378
+ dedup_counts[(item.role, item.name, item.frame_path)] = (
379
+ dedup_counts.get((item.role, item.name, item.frame_path), 0) + 1
380
+ )
381
+ for ref, data in list(refs.items()):
382
+ if dedup_counts[(data.role, data.name, data.frame_path)] == 1:
383
+ refs[ref] = replace(data, nth=None)
384
+
385
+ tree = "\n".join(result) if result else "(empty)"
386
+ return SemanticSnapshot(tree=tree, refs=refs, metadata=metadata)
@@ -0,0 +1,127 @@
1
+ """Semantic ref dataclasses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+
10
+ @dataclass(slots=True, frozen=True)
11
+ class SnapshotMetadata:
12
+ snapshot_id: str
13
+ page_id: str
14
+ captured_url: str
15
+ captured_at: float
16
+ interactive: bool
17
+ full_page: bool
18
+
19
+
20
+ @dataclass(slots=True, frozen=True)
21
+ class RefData:
22
+ ref: str
23
+ role: str
24
+ name: str | None = None
25
+ nth: int | None = None
26
+ text_content: str | None = None
27
+ tag: str | None = None
28
+ interactive: bool = False
29
+ parent_ref: str | None = None
30
+ frame_path: tuple[int, ...] = ()
31
+ playwright_ref: str | None = None
32
+ selector_recipe: str | None = None
33
+ snapshot_id: str = ""
34
+ page_id: str = ""
35
+ captured_url: str = ""
36
+ captured_at: float = 0.0
37
+ extra: dict[str, Any] = field(default_factory=dict)
38
+
39
+ def to_summary(self) -> dict[str, Any]:
40
+ return {
41
+ "ref": self.ref,
42
+ "role": self.role,
43
+ "name": self.name,
44
+ "nth": self.nth,
45
+ "text_content": self.text_content,
46
+ "tag": self.tag,
47
+ "interactive": self.interactive,
48
+ "parent_ref": self.parent_ref,
49
+ "frame_path": list(self.frame_path),
50
+ "playwright_ref": self.playwright_ref,
51
+ "selector_recipe": self.selector_recipe,
52
+ "snapshot_id": self.snapshot_id,
53
+ "page_id": self.page_id,
54
+ "captured_url": self.captured_url,
55
+ "captured_at": self.captured_at,
56
+ **self.extra,
57
+ }
58
+
59
+
60
+ @dataclass(slots=True, frozen=True)
61
+ class SemanticSnapshot:
62
+ tree: str
63
+ refs: dict[str, RefData]
64
+ metadata: SnapshotMetadata
65
+
66
+
67
+ @dataclass(slots=True, frozen=True)
68
+ class SnapshotInput:
69
+ raw_snapshot: str
70
+ captured_url: str
71
+ captured_at: float = field(default_factory=time.time)
72
+
73
+
74
+ @dataclass(slots=True, frozen=True)
75
+ class LocatorSpec:
76
+ ref: str
77
+ role: str
78
+ name: str | None = None
79
+ text_content: str | None = None
80
+ match_text: str | None = None
81
+ child_text: str | None = None
82
+ nth: int | None = None
83
+ tag: str | None = None
84
+ interactive: bool = False
85
+ frame_path: tuple[int, ...] = ()
86
+ selector_recipe: str | None = None
87
+
88
+ def to_dict(self) -> dict[str, Any]:
89
+ return {
90
+ "ref": self.ref,
91
+ "role": self.role,
92
+ "name": self.name,
93
+ "text_content": self.text_content,
94
+ "match_text": self.match_text,
95
+ "child_text": self.child_text,
96
+ "nth": self.nth,
97
+ "tag": self.tag,
98
+ "interactive": self.interactive,
99
+ "frame_path": list(self.frame_path),
100
+ "selector_recipe": self.selector_recipe,
101
+ }
102
+
103
+ @classmethod
104
+ def from_dict(cls, payload: dict[str, Any]) -> LocatorSpec:
105
+ return cls(
106
+ ref=str(payload.get("ref") or "").strip(),
107
+ role=str(payload.get("role") or "").strip(),
108
+ name=(str(payload["name"]) if payload.get("name") is not None else None),
109
+ text_content=(
110
+ str(payload["text_content"]) if payload.get("text_content") is not None else None
111
+ ),
112
+ match_text=(
113
+ str(payload["match_text"]) if payload.get("match_text") is not None else None
114
+ ),
115
+ child_text=(
116
+ str(payload["child_text"]) if payload.get("child_text") is not None else None
117
+ ),
118
+ nth=(int(payload["nth"]) if payload.get("nth") is not None else None),
119
+ tag=(str(payload["tag"]) if payload.get("tag") is not None else None),
120
+ interactive=bool(payload.get("interactive")),
121
+ frame_path=tuple(int(item) for item in (payload.get("frame_path") or [])),
122
+ selector_recipe=(
123
+ str(payload["selector_recipe"])
124
+ if payload.get("selector_recipe") is not None
125
+ else None
126
+ ),
127
+ )
@@ -0,0 +1,51 @@
1
+ """Tab-scoped semantic snapshot registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from browser_cli.refs.models import RefData, SemanticSnapshot
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class PageSnapshotState:
12
+ snapshot_id: str
13
+ tree: str
14
+ refs: dict[str, RefData]
15
+ captured_url: str
16
+ captured_at: float
17
+
18
+ @classmethod
19
+ def from_snapshot(cls, snapshot: SemanticSnapshot) -> PageSnapshotState:
20
+ return cls(
21
+ snapshot_id=snapshot.metadata.snapshot_id,
22
+ tree=snapshot.tree,
23
+ refs=dict(snapshot.refs),
24
+ captured_url=snapshot.metadata.captured_url,
25
+ captured_at=snapshot.metadata.captured_at,
26
+ )
27
+
28
+
29
+ class SnapshotRegistry:
30
+ def __init__(self) -> None:
31
+ self._latest_by_page: dict[str, PageSnapshotState] = {}
32
+
33
+ def store(self, snapshot: SemanticSnapshot) -> PageSnapshotState:
34
+ state = PageSnapshotState.from_snapshot(snapshot)
35
+ self._latest_by_page[snapshot.metadata.page_id] = state
36
+ return state
37
+
38
+ def get(self, page_id: str) -> PageSnapshotState | None:
39
+ return self._latest_by_page.get(page_id)
40
+
41
+ def get_ref(self, page_id: str, ref: str) -> RefData | None:
42
+ state = self.get(page_id)
43
+ if state is None:
44
+ return None
45
+ return state.refs.get(ref)
46
+
47
+ def clear_page(self, page_id: str) -> None:
48
+ self._latest_by_page.pop(page_id, None)
49
+
50
+ def clear(self) -> None:
51
+ self._latest_by_page.clear()