github-router 0.3.45 → 0.3.66

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.
@@ -2,7 +2,7 @@
2
2
  "manifest_version": 3,
3
3
  "name": "github-router browser bridge",
4
4
  "short_name": "gh-router-browser",
5
- "version": "0.0.1",
5
+ "version": "0.3.66",
6
6
  "description": "Bridge between Claude (via github-router /mcp) and the browser. Implements tab control, navigation, clicks, form fill, downloads, screenshots, devtools eval. Blocks navigation to chrome://settings.",
7
7
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqJElxuBlonBS3TVW9FJN0mGTtShB3L1hoaYf6k39SOr1ogGYmF90EjRxy1i21k9wQQjPf26bcBu/9X67KrQjQV0uB38CaNukgiSeoLjfptN811u+PJHx6BP+jx3Qa6/3VenNPxHC8WEU0GXql8QSjIHEyCwKb6fMASXOK94JyB5Ywov2x8mt/+9ncqBBBMVzf6r5Sagy4PL1XnryLsuADD/vOEkPet8wXgH/Oj7v5tTsQQZ7U1JT51PoDs2BFnXc5v3TkVgZwd32k3ONh+nkDw1Hof+4zwUGOyJE6eMrlYzRlKM4Qxdf9JpavQvqfieAbTRWcyKeclnHeoIfE7cDBQIDAQAB",
8
8
  "background": {
@@ -16,9 +16,12 @@
16
16
  "scripting",
17
17
  "downloads",
18
18
  "webNavigation",
19
+ "webRequest",
19
20
  "debugger",
20
21
  "storage",
21
22
  "alarms"
22
23
  ],
23
- "host_permissions": ["<all_urls>"]
24
+ "host_permissions": [
25
+ "<all_urls>"
26
+ ]
24
27
  }
@@ -0,0 +1,438 @@
1
+ // snapshot-cdp.js — CDP `Accessibility.getFullAXTree` based extractor.
2
+ //
3
+ // Why CDP over the in-page DOM walker:
4
+ // - Cross-origin iframes: chrome.scripting.executeScript can't enter
5
+ // them by default (Stripe checkout, OAuth widgets, embedded payment
6
+ // forms). CDP's Page.getFrameTree enumerates all frames including
7
+ // OOPIFs; Accessibility.getFullAXTree({frameId}) returns each
8
+ // frame's a11y tree.
9
+ // - Platform-computed accessible name: handles aria-labelledby chains,
10
+ // <label for>, fieldset/legend, button-inside-link cases the
11
+ // hand-rolled `nameOf` walker misses.
12
+ // - Real ignored state: Chrome's a11y tree marks nodes that screen
13
+ // readers skip (display:none, aria-hidden, role=presentation). We
14
+ // filter those upfront instead of guessing visibility from bbox.
15
+ //
16
+ // The extractor produces a PageSnapshot matching snapshot-types.ts.
17
+ // Falls through to the legacy DOM-walker extractor when CDP attach
18
+ // fails (enterprise DeveloperToolsAvailability=2, DevTools already
19
+ // open on the tab, etc.).
20
+
21
+ const ELEMENT_CAP = 500 // total elements across all frames
22
+ const PER_FRAME_CAP = 200 // per-frame element cap
23
+ const TEXT_CAP = 32 * 1024 // viewport-visible text cap
24
+ const VS_MIN = 100 // visualSurface min width / height
25
+ const CAPTURE_TIMEOUT_MS = 8000 // whole-snapshot wall-clock cap
26
+
27
+ // Roles we consider "interactive enough to bother surfacing." A
28
+ // liberal allowlist; the matcher cascade scores candidates further.
29
+ const INTERESTING_ROLES = new Set([
30
+ "button", "link", "checkbox", "radio", "switch", "tab", "menuitem",
31
+ "menuitemcheckbox", "menuitemradio", "option", "treeitem",
32
+ "textbox", "searchbox", "combobox", "spinbutton", "slider",
33
+ "listbox", "listitem", "tablist", "tabpanel",
34
+ "dialog", "alertdialog", "navigation", "main", "form", "search",
35
+ "region", "complementary", "banner", "contentinfo", "article",
36
+ "heading",
37
+ ])
38
+
39
+ const LANDMARK_ROLES = new Set([
40
+ "dialog", "alertdialog", "navigation", "main", "form", "search",
41
+ "region", "complementary", "banner", "contentinfo",
42
+ ])
43
+
44
+ const INTERACTIVE_LEAF_ROLES = new Set([
45
+ "button", "link", "checkbox", "radio", "switch", "tab", "menuitem",
46
+ "menuitemcheckbox", "menuitemradio", "option", "treeitem",
47
+ "textbox", "searchbox", "combobox", "spinbutton", "slider",
48
+ ])
49
+
50
+ /**
51
+ * Extract a page snapshot via CDP. Throws on attach failure or
52
+ * whole-capture timeout — caller falls back to the legacy extractor.
53
+ *
54
+ * `attachDebugger` and `sendCommand` are passed in so this module
55
+ * stays decoupled from the SW-global debugger state in background.js;
56
+ * makes the extractor unit-testable with a mock CDP later.
57
+ */
58
+ export async function extractSnapshotCDP(tabId, opts, deps) {
59
+ const mode = opts?.mode === "full" ? "full" : "summary"
60
+ const attachDebugger = deps?.attachDebugger
61
+ const sendCommand = deps?.sendCommand
62
+ if (typeof attachDebugger !== "function" || typeof sendCommand !== "function") {
63
+ throw new Error("snapshot-cdp: missing attachDebugger / sendCommand deps")
64
+ }
65
+ await attachDebugger(tabId, { accessibility: true })
66
+
67
+ // Race against a whole-capture wall-clock budget so a slow
68
+ // upstream can't hang the dispatcher.
69
+ let timedOut = false
70
+ const timer = setTimeout(() => { timedOut = true }, CAPTURE_TIMEOUT_MS)
71
+ try {
72
+ const tab = await chrome.tabs.get(tabId)
73
+ const viewport = await captureViewport(tabId, sendCommand)
74
+ // DOM.getDocument must be called before DOM.pushNodesByBackendIdsToFrontend
75
+ // can resolve anything — DOM.enable alone does NOT materialize the
76
+ // protocol-side node tree, which is the gotcha that bit the first
77
+ // implementation (1597 AX nodes, 0 resolved). pierce:true crosses
78
+ // into shadow roots and iframe content documents in one round-trip.
79
+ try {
80
+ await sendCommand(tabId, "DOM.getDocument", { depth: -1, pierce: true })
81
+ } catch {
82
+ // Best-effort — getDocument can fail on detached / about:blank
83
+ // frames; pushNodes calls below will throw cleanly per-frame.
84
+ }
85
+ const frameTree = await sendCommand(tabId, "Page.getFrameTree", {})
86
+ const frames = flattenFrameTree(frameTree.frameTree)
87
+ let truncatedElements = false
88
+ let framesSkipped = 0
89
+ const elements = []
90
+ const refCounter = { next: 1 }
91
+ const usedRefs = new Set()
92
+ const diag = { frames: frames.length, axNodes: 0, interesting: 0, resolved: 0, withRef: 0 }
93
+ for (const frame of frames) {
94
+ if (timedOut) break
95
+ if (elements.length >= ELEMENT_CAP) {
96
+ truncatedElements = true
97
+ break
98
+ }
99
+ try {
100
+ const frameElements = await extractFrameElements({
101
+ tabId,
102
+ frame,
103
+ parentFrameOffset: frame.offset,
104
+ isTopFrame: frame === frames[0],
105
+ mode,
106
+ viewport,
107
+ remainingCap: ELEMENT_CAP - elements.length,
108
+ refCounter,
109
+ usedRefs,
110
+ sendCommand,
111
+ diag,
112
+ })
113
+ elements.push(...frameElements)
114
+ } catch {
115
+ framesSkipped++
116
+ // Per-frame failure (cross-origin OOPIF refusing the call,
117
+ // detached frame, sandbox restriction) is logged but not
118
+ // fatal. The top frame's failure WOULD be fatal, but its
119
+ // attach already succeeded so an enable failure is rare.
120
+ }
121
+ }
122
+ const text = await extractVisibleText(tabId, sendCommand).catch(() => "")
123
+ const truncatedText = text.length >= TEXT_CAP
124
+ const visualSurfaces = await extractVisualSurfaces(tabId, sendCommand).catch(() => [])
125
+ const out = {
126
+ mode,
127
+ tabId,
128
+ url: tab.url,
129
+ title: tab.title,
130
+ capturedAt: Date.now(),
131
+ viewport,
132
+ text: text.slice(0, TEXT_CAP),
133
+ elements,
134
+ truncated: {
135
+ elements: truncatedElements,
136
+ text: truncatedText,
137
+ framesSkipped,
138
+ diag,
139
+ },
140
+ }
141
+ if (visualSurfaces.length > 0) out.visualSurfaces = visualSurfaces
142
+ return out
143
+ } finally {
144
+ clearTimeout(timer)
145
+ }
146
+ }
147
+
148
+ function flattenFrameTree(node, out, parentOffset) {
149
+ if (!out) out = []
150
+ if (!node) return out
151
+ const frame = node.frame || node
152
+ const offset = parentOffset ?? { x: 0, y: 0 }
153
+ out.push({
154
+ frameId: frame.id,
155
+ url: frame.url,
156
+ parentId: frame.parentId,
157
+ offset,
158
+ })
159
+ if (Array.isArray(node.childFrames)) {
160
+ for (const child of node.childFrames) {
161
+ // Child offset relative to top frame: we'd need each iframe
162
+ // element's bbox to transform. Skipping for now — bbox of
163
+ // elements inside child frames is iframe-local CSS pixels.
164
+ // The matcher cascade still resolves them by ref via
165
+ // data-gh-router-ref; the bbox is best-effort.
166
+ flattenFrameTree(child, out, offset)
167
+ }
168
+ }
169
+ return out
170
+ }
171
+
172
+ async function captureViewport(tabId, sendCommand) {
173
+ try {
174
+ const layoutMetrics = await sendCommand(tabId, "Page.getLayoutMetrics", {})
175
+ const v = layoutMetrics.cssVisualViewport ?? layoutMetrics.visualViewport ?? {}
176
+ return {
177
+ width: Math.round(v.clientWidth ?? v.width ?? 0),
178
+ height: Math.round(v.clientHeight ?? v.height ?? 0),
179
+ devicePixelRatio: v.scale ?? 1,
180
+ scrollX: Math.round(v.pageX ?? 0),
181
+ scrollY: Math.round(v.pageY ?? 0),
182
+ }
183
+ } catch {
184
+ return { width: 0, height: 0, devicePixelRatio: 1, scrollX: 0, scrollY: 0 }
185
+ }
186
+ }
187
+
188
+ async function extractFrameElements({
189
+ tabId, frame, parentFrameOffset, isTopFrame, mode, viewport,
190
+ remainingCap, refCounter, usedRefs, sendCommand, diag,
191
+ }) {
192
+ const cap = Math.min(PER_FRAME_CAP, remainingCap)
193
+ const params = isTopFrame ? {} : { frameId: frame.frameId }
194
+ const result = await sendCommand(tabId, "Accessibility.getFullAXTree", params)
195
+ const nodes = Array.isArray(result.nodes) ? result.nodes : []
196
+ if (diag) diag.axNodes += nodes.length
197
+ // Pre-pass: collect landmark nodes by AXNode id so leaf nodes can
198
+ // attribute their ancestry without walking the whole tree.
199
+ const landmarkByAxId = new Map()
200
+ for (const n of nodes) {
201
+ const role = n.role?.value
202
+ if (role && LANDMARK_ROLES.has(role) && !n.ignored) {
203
+ landmarkByAxId.set(n.nodeId, n)
204
+ }
205
+ }
206
+ // Parent map for ancestry walks.
207
+ const parentById = new Map()
208
+ for (const n of nodes) {
209
+ if (Array.isArray(n.childIds)) {
210
+ for (const cid of n.childIds) parentById.set(cid, n.nodeId)
211
+ }
212
+ }
213
+ function landmarksOf(axId) {
214
+ const refs = []
215
+ let cur = parentById.get(axId)
216
+ while (cur !== undefined && refs.length < 4) {
217
+ if (landmarkByAxId.has(cur)) {
218
+ const lm = landmarkByAxId.get(cur)
219
+ const r = lm._ghRouterRef
220
+ if (r) refs.push(r)
221
+ }
222
+ cur = parentById.get(cur)
223
+ }
224
+ return refs
225
+ }
226
+ // Filter to interesting + has backendDOMNodeId; cap per-frame.
227
+ const interesting = nodes.filter((n) => {
228
+ if (n.ignored) return false
229
+ const role = n.role?.value
230
+ if (!role || !INTERESTING_ROLES.has(role)) return false
231
+ if (typeof n.backendDOMNodeId !== "number") return false
232
+ return true
233
+ }).slice(0, cap)
234
+ if (diag) diag.interesting += interesting.length
235
+ // Resolve backendDOMNodeIds to frontend nodeIds in one batch.
236
+ const backendIds = interesting.map((n) => n.backendDOMNodeId)
237
+ let nodeIds = []
238
+ if (backendIds.length > 0) {
239
+ try {
240
+ const resolved = await sendCommand(tabId, "DOM.pushNodesByBackendIdsToFrontend", {
241
+ backendNodeIds: backendIds,
242
+ })
243
+ nodeIds = Array.isArray(resolved.nodeIds) ? resolved.nodeIds : []
244
+ if (diag) diag.resolved += nodeIds.filter((n) => n).length
245
+ } catch {
246
+ // DOM.pushNodes can fail per-frame on cross-origin. Best-effort.
247
+ }
248
+ }
249
+ const out = []
250
+ for (let i = 0; i < interesting.length; i++) {
251
+ const ax = interesting[i]
252
+ const nodeId = nodeIds[i]
253
+ if (!nodeId) continue
254
+ // Read existing ref or mint new one.
255
+ let ref
256
+ try {
257
+ const attrs = await sendCommand(tabId, "DOM.getAttributes", { nodeId })
258
+ ref = attrFromList(attrs.attributes, "data-gh-router-ref")
259
+ } catch {
260
+ ref = undefined
261
+ }
262
+ if (!ref || !/^e\d+$/.test(ref)) {
263
+ while (usedRefs.has(`e${refCounter.next}`)) refCounter.next++
264
+ ref = `e${refCounter.next}`
265
+ refCounter.next++
266
+ usedRefs.add(ref)
267
+ try {
268
+ await sendCommand(tabId, "DOM.setAttributeValue", {
269
+ nodeId, name: "data-gh-router-ref", value: ref,
270
+ })
271
+ } catch {
272
+ // Read-only doc or shadow boundary; skip this element.
273
+ continue
274
+ }
275
+ } else {
276
+ usedRefs.add(ref)
277
+ }
278
+ ax._ghRouterRef = ref
279
+ // Get bbox via DOM.getBoxModel. Best-effort; off-screen / detached
280
+ // nodes throw.
281
+ let bbox = [0, 0, 0, 0]
282
+ try {
283
+ const box = await sendCommand(tabId, "DOM.getBoxModel", { nodeId })
284
+ const m = box.model
285
+ if (m && Array.isArray(m.border) && m.border.length >= 4) {
286
+ // border is [x1,y1, x2,y1, x2,y2, x1,y2] — 8 numbers
287
+ const x = m.border[0]
288
+ const y = m.border[1]
289
+ const w = m.width
290
+ const h = m.height
291
+ bbox = [
292
+ Math.round(x + parentFrameOffset.x),
293
+ Math.round(y + parentFrameOffset.y),
294
+ Math.round(w),
295
+ Math.round(h),
296
+ ]
297
+ }
298
+ } catch {
299
+ // ignore
300
+ }
301
+ // Skip elements with zero-size bbox in summary mode — they're
302
+ // hidden by display:none-ish parent or detached.
303
+ if (mode === "summary" && bbox[2] === 0 && bbox[3] === 0) continue
304
+ // Skip elements outside the viewport in summary mode (top-frame
305
+ // coord space). Skip when bbox is unknown (0,0,0,0) too.
306
+ if (mode === "summary" && !inViewport(bbox, viewport) && bbox.some((n) => n !== 0)) continue
307
+ const role = ax.role?.value || "generic"
308
+ const name = (ax.name?.value || "").trim().slice(0, 200)
309
+ const description = (ax.description?.value || "").trim().slice(0, 200)
310
+ const valueStr = (ax.value?.value !== undefined && ax.value?.value !== null)
311
+ ? String(ax.value.value).trim().slice(0, 200)
312
+ : ""
313
+ // Drop unnamed leaf interactive elements in summary mode (a
314
+ // <button> with no accessible name is noise).
315
+ if (mode === "summary" && !name && INTERACTIVE_LEAF_ROLES.has(role)) continue
316
+ const entry = {
317
+ ref,
318
+ role,
319
+ bbox,
320
+ frameId: frame.frameId,
321
+ }
322
+ if (!isTopFrame) entry.isInIframe = true
323
+ if (name) entry.name = name
324
+ if (description) entry.description = description
325
+ if (valueStr) entry.value = valueStr
326
+ // State flags from AXNode.properties — Chrome surfaces these in
327
+ // a strongly-typed bag distinct from the generic value field.
328
+ const props = ax.properties
329
+ if (Array.isArray(props)) {
330
+ for (const p of props) {
331
+ const k = p.name
332
+ const v = p.value?.value
333
+ if (k === "disabled" && v) entry.disabled = true
334
+ else if (k === "focused" && v) entry.focused = true
335
+ else if (k === "checked" && v !== undefined && v !== false) {
336
+ entry.checked = v === "mixed" ? "mixed" : Boolean(v)
337
+ }
338
+ else if (k === "expanded" && v !== undefined) entry.expanded = Boolean(v)
339
+ else if (k === "selected" && v) entry.selected = true
340
+ else if (k === "pressed" && v !== undefined && v !== false) entry.pressed = Boolean(v)
341
+ else if (k === "hidden" && v) entry.hidden = true
342
+ else if (k === "required" && v) entry.required = true
343
+ else if (k === "readonly" && v) entry.readonly = true
344
+ else if (k === "invalid" && v) entry.invalid = v === true ? true : String(v)
345
+ else if (k === "editable" && v) entry.editable = true
346
+ else if (k === "level" && typeof v === "number") entry.level = v
347
+ }
348
+ }
349
+ // Landmark ancestry (ref-chain up to 4 deep).
350
+ const lm = landmarksOf(ax.nodeId)
351
+ if (lm.length > 0) entry.landmarks = lm
352
+ out.push(entry)
353
+ if (diag) diag.withRef++
354
+ }
355
+ return out
356
+ }
357
+
358
+ function inViewport(bbox, viewport) {
359
+ const [x, y, w, h] = bbox
360
+ return x < viewport.width && y < viewport.height && x + w > 0 && y + h > 0
361
+ }
362
+
363
+ function attrFromList(attrList, name) {
364
+ if (!Array.isArray(attrList)) return undefined
365
+ for (let i = 0; i < attrList.length; i += 2) {
366
+ if (attrList[i] === name) return attrList[i + 1]
367
+ }
368
+ return undefined
369
+ }
370
+
371
+ async function extractVisibleText(tabId, sendCommand) {
372
+ // Single Runtime.evaluate call into the page's main world to grab
373
+ // viewport-visible text. Same logic as the legacy extractor.
374
+ const expr = `
375
+ (function() {
376
+ const out = [];
377
+ let total = 0;
378
+ const CAP = ${TEXT_CAP};
379
+ const root = document.body || document.documentElement;
380
+ if (!root) return "";
381
+ const tw = document.createTreeWalker(root, 4);
382
+ const vp = { w: window.innerWidth, h: window.innerHeight };
383
+ function inV(r) { return r.bottom > 0 && r.right > 0 && r.top < vp.h && r.left < vp.w; }
384
+ let n;
385
+ while ((n = tw.nextNode())) {
386
+ const p = n.parentElement;
387
+ if (!p) continue;
388
+ const t = p.tagName ? p.tagName.toLowerCase() : "";
389
+ if (t === "script" || t === "style" || t === "noscript") continue;
390
+ const r = p.getBoundingClientRect();
391
+ if (!inV(r)) continue;
392
+ const s = (n.textContent || "").replace(/\\s+/g, " ").trim();
393
+ if (!s) continue;
394
+ if (total + s.length + 1 > CAP) { out.push(s.slice(0, Math.max(0, CAP - total))); break; }
395
+ out.push(s);
396
+ total += s.length + 1;
397
+ }
398
+ return out.join("\\n");
399
+ })()
400
+ `
401
+ const res = await sendCommand(tabId, "Runtime.evaluate", {
402
+ expression: expr,
403
+ returnByValue: true,
404
+ })
405
+ return res?.result?.value ?? ""
406
+ }
407
+
408
+ async function extractVisualSurfaces(tabId, sendCommand) {
409
+ // Find canvas / svg of non-trivial size in viewport. Runtime.evaluate
410
+ // is the cheapest path; AX tree doesn't surface canvas/svg directly.
411
+ const expr = `
412
+ (function() {
413
+ const out = [];
414
+ const vp = { w: window.innerWidth, h: window.innerHeight };
415
+ function inV(r) { return r.bottom > 0 && r.right > 0 && r.top < vp.h && r.left < vp.w; }
416
+ const els = document.querySelectorAll("canvas, svg");
417
+ let counter = 1;
418
+ for (const el of els) {
419
+ const r = el.getBoundingClientRect();
420
+ if (r.width < ${VS_MIN} || r.height < ${VS_MIN}) continue;
421
+ if (!inV(r)) continue;
422
+ let ref = el.getAttribute("data-gh-router-ref");
423
+ if (!ref) { ref = "v" + counter++; el.setAttribute("data-gh-router-ref", ref); }
424
+ out.push({
425
+ ref,
426
+ kind: el.tagName.toLowerCase(),
427
+ bbox: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)],
428
+ });
429
+ }
430
+ return out;
431
+ })()
432
+ `
433
+ const res = await sendCommand(tabId, "Runtime.evaluate", {
434
+ expression: expr,
435
+ returnByValue: true,
436
+ })
437
+ return Array.isArray(res?.result?.value) ? res.result.value : []
438
+ }
@@ -0,0 +1,101 @@
1
+ // snapshot.js — extension-side snapshot pipeline for the deterministic-
2
+ // first browser MCP refactor.
3
+ //
4
+ // Phase 1b ships the CACHE + INVALIDATION infrastructure with the legacy
5
+ // `document.querySelectorAll`-based extraction still doing the actual
6
+ // work (delegated back to background.js). Phase 1b-CDP (next commit)
7
+ // swaps the extractor for a CDP `Accessibility.getFullAXTree`-based
8
+ // implementation that auto-stitches Shadow DOM + cross-origin iframes
9
+ // and populates the rich state fields the matcher cascade needs.
10
+ //
11
+ // Public surface:
12
+ // captureSnapshot(tabId, opts) - extract a fresh snapshot (writes cache)
13
+ // getCachedSnapshot(tabId) - read-through cache, undefined on miss
14
+ // invalidateSnapshot(tabId, reason) - clear cache for a tab
15
+ // anyCachedSnapshots() - cheap predicate (cache-hit early-exit)
16
+ //
17
+ // Cache invalidation is action-driven, not time-driven. Mutating tools
18
+ // (click / fill / type / keyboard / scroll / mouse / drag) flag
19
+ // `mutatesPage: true` in the dispatcher; that flag triggers an
20
+ // invalidation in the success path. Navigation + tab-close trigger
21
+ // invalidations via the listeners registered alongside the existing
22
+ // debugger.onDetach / tabs.onRemoved handlers in background.js.
23
+
24
+ const cache = new Map() // tabId -> PageSnapshot
25
+ // Debug logging is off by default. Flip via globalThis.__GH_ROUTER_DEBUG_SNAPSHOT
26
+ // = true from the extension's service-worker console for ad-hoc tracing.
27
+ // Avoids depending on process.env (not available in extension context)
28
+ // or chrome.storage (sync I/O on the hot path).
29
+ function isDebug() {
30
+ return Boolean(globalThis.__GH_ROUTER_DEBUG_SNAPSHOT)
31
+ }
32
+
33
+ /**
34
+ * Read-through cache lookup. Returns undefined on miss.
35
+ */
36
+ export function getCachedSnapshot(tabId) {
37
+ return cache.get(tabId)
38
+ }
39
+
40
+ /**
41
+ * True if any tab currently has a cached snapshot. Used by the matcher
42
+ * cascade's fast-path bypass — when nothing is cached, no point
43
+ * consulting per-tab state.
44
+ */
45
+ export function anyCachedSnapshots() {
46
+ return cache.size > 0
47
+ }
48
+
49
+ /**
50
+ * Drop the cache entry for a tab. `reason` is logged when the debug
51
+ * channel is on; helps diagnose "why is my cache cold?" without a
52
+ * breakpoint. Idempotent — calling on a missing tabId is a no-op.
53
+ */
54
+ export function invalidateSnapshot(tabId, reason) {
55
+ if (!cache.has(tabId)) return
56
+ cache.delete(tabId)
57
+ if (isDebug()) {
58
+ console.debug(`[browser-mcp/snapshot] invalidated tab ${tabId} (${reason})`)
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Drop every cached snapshot. Used on bridge / extension restart paths.
64
+ */
65
+ export function clearAllSnapshots() {
66
+ cache.clear()
67
+ }
68
+
69
+ /**
70
+ * Capture a fresh snapshot. The extractor is passed in by the caller so
71
+ * this module stays decoupled from the specific extraction strategy —
72
+ * legacy DOM walker today, CDP a11y tree once Phase 1b-CDP lands. The
73
+ * extractor function receives `(tabId, opts)` and returns a
74
+ * `PageSnapshot`-shaped object.
75
+ *
76
+ * The captured snapshot is written to the cache before being returned,
77
+ * so a follow-up `getCachedSnapshot(tabId)` in the same turn is a hit.
78
+ * Caller passes `refresh: true` in opts to force a re-capture (the
79
+ * snapshot is still written through the cache).
80
+ */
81
+ export async function captureSnapshot(tabId, opts, extractor) {
82
+ if (typeof tabId !== "number") {
83
+ throw new Error("snapshot.captureSnapshot: tabId must be a number")
84
+ }
85
+ if (typeof extractor !== "function") {
86
+ throw new Error("snapshot.captureSnapshot: extractor must be a function")
87
+ }
88
+ const refresh = opts?.refresh === true
89
+ if (!refresh) {
90
+ const cached = cache.get(tabId)
91
+ if (cached) return cached
92
+ }
93
+ const snapshot = await extractor(tabId, opts)
94
+ // Normalize: ensure tabId + capturedAt are populated even when the
95
+ // extractor doesn't supply them, so downstream consumers can rely on
96
+ // these without optional-chaining.
97
+ snapshot.tabId = tabId
98
+ snapshot.capturedAt = Date.now()
99
+ cache.set(tabId, snapshot)
100
+ return snapshot
101
+ }