@evan_zhong/browser-attach 0.11.2 → 0.12.0
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.
- package/docs/ARCHITECTURE.md +2 -1
- package/docs/TEST-REPORT.md +4 -4
- package/docs/USAGE.md +8 -2
- package/extension/click.js +8 -3
- package/extension/controller.js +5 -4
- package/extension/drag.js +19 -2
- package/extension/manifest.json +1 -1
- package/extension/snapshot.js +44 -5
- package/package.json +1 -1
- package/src/config.mjs +1 -1
- package/src/mcp.mjs +4 -1
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 架构与设计边界
|
|
2
2
|
|
|
3
|
-
当前实现为 **0.
|
|
3
|
+
当前实现为 **0.12.0**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
|
|
4
4
|
|
|
5
5
|
## 组件与数据流
|
|
6
6
|
|
|
@@ -57,6 +57,7 @@ claim 与普通输入都不激活标签或窗口,也不移动系统鼠标;
|
|
|
57
57
|
## 观察模型
|
|
58
58
|
|
|
59
59
|
- **AX 渲染**:从 `Accessibility.getFullAXTree` 读取,移除 `InlineTextBox`,合并纯文本列表项与段落名称,丢弃 `generic` / `none` 且无名称无状态的节点,只保留少量状态属性和浏览器提供的 value、invalid。每行 `[token:n] role "name"`,`token` 是本次观察的随机前缀;这些字段不证明应用已保存。
|
|
60
|
+
- **交互视图**:可选 `profile=interactive` 在同一渲染器中仅保留 AX 控件角色、可聚焦/可编辑节点及其祖先链和控件内文本,不以 backendNodeId/ref 是否存在判断交互性。独立文本和合并正文可隐藏或截取预览,控件名称、值和 ref 的原始 AX 身份保留;默认 `full` 行为不变。筛选仍读取完整 AX 树,只降低输出与分页成本;视图参数绑定分页观察。
|
|
60
61
|
- **ref 解析**:ref 保存 `backendNodeId`、所属 frame/session 和观察时的 AX role/name;动作时重新 `DOM.resolveNode` 并用局部 AX 查询核对语义身份。输入与求值不清空 refs,只使快照分页失效;原节点仍连接时可复用,节点替换、role/name 改变、文档或 frame 变化时安全失败,不按 role/name 猜测替代节点。
|
|
61
62
|
- **双预算**:`max_nodes` 限制节点数,`max_chars` 限制每行序列化后的累计字符数;两者独立,任一耗尽都会返回 `next_cursor`。
|
|
62
63
|
- **续读**:续读沿用同一份 `state`(`entries`、`refs`、`nextOffset`),所以 refs 在分页过程中保持有效。单个节点放不进预算时直接报错要求提高预算,避免静默截断尾部。
|
package/docs/TEST-REPORT.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# 验证报告
|
|
2
2
|
|
|
3
|
-
当前版本 **0.
|
|
3
|
+
当前版本 **0.12.0**(2026-10-22)。验证使用本地 fixture 和临时 profile,链路为扩展 → 本机 Hub → stdio MCP。
|
|
4
4
|
|
|
5
5
|
## 当前版本
|
|
6
6
|
|
|
7
|
-
- `npm test`:
|
|
8
|
-
- `npm run test:package`:仓库外含空格路径安装运行通过,22 个 MCP 工具、45 个发布文件;包含 `drag.js` / `wait.js` / `scroll.js` / `outcomes.js`,不包含配对密钥。版本为 0.
|
|
9
|
-
-
|
|
7
|
+
- `npm test`:149 项通过。覆盖 interactive 快照的控件/祖先筛选、文本预览与原始 ref 身份、视图绑定分页、独占控制、快照/ref 生命周期、严格 selector 与有界歧义候选、原生/DOM 输入、逐字输入、hover、pointer/HTML5 拖拽、document 拖拽坐标差值与按轴滚动、滚动边界后的延迟加载、条件等待、HTML select、batch、历史导航、OOPIF、日志、上传、截图、对话框和未知结果不重放。
|
|
8
|
+
- `npm run test:package`:仓库外含空格路径安装运行通过,22 个 MCP 工具、45 个发布文件;包含 `drag.js` / `wait.js` / `scroll.js` / `outcomes.js`,不包含配对密钥。版本为 0.12.0,示例配置使用 `@latest`。
|
|
9
|
+
- Chromium headless `--primitives`:16 个场景通过,包含 interactive 快照、document 坐标纵向 pointer/HTML5 拖拽、普通 pointer/HTML5 拖拽、取消 pointerdown 默认行为后的可信回执、滚动边界后延迟增长和歧义候选。含 200 段正文的 fixture 中,默认 full 首屏 23302 字符且仍需续页,interactive 为 947 字符且无需续页;该比较只衡量返回内容,不代表 AX 采集提速。
|
|
10
10
|
|
|
11
11
|
## 升级前的浏览器验证
|
|
12
12
|
|
package/docs/USAGE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# BrowserAttach 使用指南
|
|
2
2
|
|
|
3
|
-
适用于 **0.
|
|
3
|
+
适用于 **0.12.0**:Node.js 22+、Chrome / Edge 125+、22 个 MCP 工具。安装、配对与升级见 [README](../README.md);内部实现与安全模型见 [架构说明](ARCHITECTURE.md)。
|
|
4
4
|
|
|
5
5
|
工具发现采用三层说明:初始化 `instructions` 提供跨工具规则,各工具 description 和参数 schema 说明自身行为与约束,本文保留完整边界和示例。客户端集成时应把 MCP 初始化响应中的 instructions 传入模型上下文;仅读取 tools/list 不会包含完整的通用调用规则。
|
|
6
6
|
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
|
|
36
36
|
| 工具 | 主要参数 | 说明 |
|
|
37
37
|
|---|---|---|
|
|
38
|
-
| `browser_snapshot` | `max_nodes=600`, `max_chars=24000`, `cursor` | AX 语义树、元素 ref、frame 覆盖、待处理文件选择器 |
|
|
38
|
+
| `browser_snapshot` | `profile`, `text`, `text_limit`, `max_nodes=600`, `max_chars=24000`, `cursor` | AX 语义树、元素 ref、frame 覆盖、待处理文件选择器 |
|
|
39
39
|
| `browser_wait` | `condition`, `selector`, `expected` / `previous`, `idle_ms=500`, `timeout_ms=5000` | 只读等待元素、文本、URL 或被动网络空闲 |
|
|
40
40
|
| `browser_read_text` | `mode`, `selector`, `offset=0`, `max_chars=20000` | 主 frame 的 DOM、字段值或支持的编辑器模型 |
|
|
41
41
|
|
|
@@ -72,7 +72,11 @@
|
|
|
72
72
|
|
|
73
73
|
- 渲染时移除 `InlineTextBox`,合并纯文本列表项与段落名称,保留 `disabled`、`focused`、`expanded`、`checked`、`selected`、`required`、`invalid`、`level` 等状态,以及浏览器提供的 `value`;`[token:n]` 即后续动作使用的 `ref`。未提供 invalid/selected 等属性不代表有效或已提交;value 也可能只是下拉框的过滤文本。
|
|
74
74
|
- `max_nodes` 为 1–3000(默认 600);`max_chars` 为 1–2000000(默认 24000),按行序列化长度累计,与节点数是两个独立预算。
|
|
75
|
+
- `profile` 默认为 `full`。`interactive` 保留 AX 控件角色、可聚焦/可编辑节点及其祖先链和控件内的文本,略去无关正文;禁用控件仍保留。仅按是否有 ref 筛选无法有效压缩,因为段落、列表项等也可能有 ref。自定义控件未提供可访问角色或可聚焦/可编辑属性时可能被省略,需用 `full` 观察。
|
|
76
|
+
- `text` 默认 `true`;`false` 隐去独立文本和纯文本段落/列表项的合并正文,仍保留可访问名称及控件值。`text_limit` 为 0–10000,限制每个文本/合并正文名称并以 `…` 标明省略;`interactive` 默认 80,`full` 默认不截断。需要更多正文时重新请求 `full`,或显式提高 `text_limit`。控件名称、值及 ref 校验所用身份始终完整。
|
|
77
|
+
- 返回 `profile` / `text` / `text_limit` 表明本次视图;`node_count` 和 `total_node_count` 统计筛选后的节点。`truncated` / `next_cursor` 只表示该视图尚有分页,不表示视图包含全部网页正文。筛选发生在完整 AX 树读取后,减少返回内容与分页次数,不减少 CDP 的 AX 树采集量。
|
|
75
78
|
- `truncated: true` 时把 `next_cursor` 传回同一个工具继续读取同一份观察,refs 在续读期间保持有效;也可以调整预算。单个超长节点放不下时会明确报出所需预算,不静默丢尾部。
|
|
79
|
+
- 续读可省略视图参数,沿用原 `profile` / `text` / `text_limit`;显式提供时必须与原观察相同。调整视图须请求新快照,并按下表使旧 ref 失效。
|
|
76
80
|
- 覆盖主 frame、同进程 iframe 与递归 OOPIF;读取失败、读取期间变化或附着失败的 frame 列入 `omitted_frames`。
|
|
77
81
|
|
|
78
82
|
失效规则:
|
|
@@ -150,6 +154,8 @@ hover 接受与原生 click 相同的目标,执行一次 mouseMoved,不派
|
|
|
150
154
|
|
|
151
155
|
drag 的 source / destination 各自接受 ref、严格 selector(可带 frame_id/nth)或 x/y(可带 coordinate_space)。mode 必须按页面交互选择,默认 pointer;steps 默认 20,范围 2–100,每步间隔约 16 ms。
|
|
152
156
|
|
|
157
|
+
两端均为 document 坐标且能同时容纳于一个顶层视口时,在按下前统一滚动位置,保留两端的 x/y 差值。已在视口内的 document 坐标不重复居中;需要滚动时只调整越界的轴。跨屏目标仍在按住后滚入视口,因此返回的 from/to 是各自派发时的视口坐标,不能用两者之差推断跨屏的文档距离。
|
|
158
|
+
|
|
153
159
|
```json
|
|
154
160
|
{"tab_handle":"...","source":{"selector":"#card"},"destination":{"selector":"#column"},"mode":"html5","steps":20}
|
|
155
161
|
```
|
package/extension/click.js
CHANGED
|
@@ -67,9 +67,14 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
|
|
|
67
67
|
view = await budget.run(() => viewport(target));
|
|
68
68
|
if (args.coordinate_space === 'document') {
|
|
69
69
|
if (!Number.isFinite(args.x) || !Number.isFinite(args.y) || args.x < 0 || args.y < 0) throw new Error('Document coordinates must be finite and nonnegative');
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
const x = args.x - view.scroll_x, y = args.y - view.scroll_y;
|
|
71
|
+
if (args.scroll !== false && (x < 0 || y < 0 || x >= view.width || y >= view.height)) {
|
|
72
|
+
const left = x < 0 || x >= view.width ? Math.max(0, args.x - view.width / 2) : view.scroll_x;
|
|
73
|
+
const top = y < 0 || y >= view.height ? Math.max(0, args.y - view.height / 2) : view.scroll_y;
|
|
74
|
+
value(await send('Runtime.evaluate', { returnByValue: true,
|
|
75
|
+
expression: `scrollTo({left:${left},top:${top},behavior:'instant'})` }));
|
|
76
|
+
view = await budget.run(() => viewport(target));
|
|
77
|
+
}
|
|
73
78
|
point = position(view, args.x - view.scroll_x, args.y - view.scroll_y);
|
|
74
79
|
} else point = position(view, args.x, args.y);
|
|
75
80
|
}
|
package/extension/controller.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Browser operations live here; the WebSocket transport never exposes raw CDP.
|
|
2
2
|
import { createVisuals } from './visuals.js';
|
|
3
3
|
import { createClickVisuals } from './click-visuals.js';
|
|
4
|
-
import { renderAX, snapshotPage } from './snapshot.js';
|
|
4
|
+
import { renderAX, snapshotOptions, snapshotPage } from './snapshot.js';
|
|
5
5
|
import { createNavigation } from './navigation.js';
|
|
6
6
|
import { createClick, clickOptions, framePoint } from './click.js';
|
|
7
7
|
import { createInput, inputMode, selectOptions, typeOptions } from './input.js';
|
|
@@ -38,7 +38,7 @@ export function createController(api, notify = () => {}) {
|
|
|
38
38
|
for (const key of pageContexts.keys()) { const [tab, session] = JSON.parse(key); if (tab === tabId && session === sessionId) pageContexts.delete(key); }
|
|
39
39
|
}, tabId => diagnostics.networkOptions(tabId));
|
|
40
40
|
const mouse = createClick(cdp, value, viewport, position, sessions, tabId => api.tabs.get(tabId), windowId => api.windows.get(windowId));
|
|
41
|
-
const gestures = createDrag(cdp, value, mouse);
|
|
41
|
+
const gestures = createDrag(cdp, value, mouse, viewport);
|
|
42
42
|
const input = createInput(cdp, value, sessions);
|
|
43
43
|
const evaluations = createEvaluations(cdp);
|
|
44
44
|
|
|
@@ -291,6 +291,7 @@ export function createController(api, notify = () => {}) {
|
|
|
291
291
|
if (!state) throw new Error('Snapshot cursor is stale or unknown. Take a new browser_snapshot.');
|
|
292
292
|
return snapshotPage(state, args);
|
|
293
293
|
}
|
|
294
|
+
const options = snapshotOptions(args);
|
|
294
295
|
snapshots.delete(tabId);
|
|
295
296
|
const documentVersion = documents.get(tabId);
|
|
296
297
|
const versions = new Map(frameStates.get(tabId)?.versions);
|
|
@@ -329,12 +330,12 @@ export function createController(api, notify = () => {}) {
|
|
|
329
330
|
knownFrames.add(childId); frames.push({ id: childId, url });
|
|
330
331
|
}
|
|
331
332
|
entries.push({ node: false, frameId: frame.id, line: `Frame ${frame.id} ${frame.url}` });
|
|
332
|
-
entries.push(...renderAX(nodes, token, refs, frame.id, target.sessionId));
|
|
333
|
+
entries.push(...renderAX(nodes, token, refs, frame.id, target.sessionId, options));
|
|
333
334
|
}
|
|
334
335
|
const metadata = value(await send(tabId, 'Runtime.evaluate', {
|
|
335
336
|
expression: '({title: document.title, url: location.href})', returnByValue: true,
|
|
336
337
|
}));
|
|
337
|
-
const state = { id: token, refs, entries, invalidFrames: new Set(), totalNodes: entries.filter(entry => entry.node).length,
|
|
338
|
+
const state = { id: token, options, refs, entries, invalidFrames: new Set(), totalNodes: entries.filter(entry => entry.node).length,
|
|
338
339
|
metadata: { title: metadata.title, url: metadata.url, viewport: await (budget ? budget.run(() => viewport(tabId)) : viewport(tabId)), omitted_frames: omittedFrames } };
|
|
339
340
|
if (documentVersion !== documents.get(tabId)) throw new Error('Document changed while reading the snapshot. Take a new browser_snapshot.');
|
|
340
341
|
for (const frame of frames) if (versions.get(frame.id) !== frameStates.get(tabId)?.versions.get(frame.id)) {
|
package/extension/drag.js
CHANGED
|
@@ -3,12 +3,15 @@ import { targetOptions } from './locator.js';
|
|
|
3
3
|
|
|
4
4
|
export function dragOptions(args) {
|
|
5
5
|
if (!args.source || !args.destination) throw new Error('drag requires source and destination');
|
|
6
|
-
for (const target of [args.source, args.destination])
|
|
6
|
+
for (const target of [args.source, args.destination]) {
|
|
7
|
+
targetOptions(target, { coordinates: true });
|
|
8
|
+
if (target.coordinate_space === 'document' && (target.x < 0 || target.y < 0)) throw new Error('Document coordinates must be finite and nonnegative');
|
|
9
|
+
}
|
|
7
10
|
if (!['pointer', 'html5'].includes(args.mode ?? 'pointer')) throw new Error('drag mode must be pointer or html5');
|
|
8
11
|
if (!Number.isInteger(args.steps ?? 20) || (args.steps ?? 20) < 2 || (args.steps ?? 20) > 100) throw new Error('steps must be 2..100');
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
export function createDrag(cdp, value, mouse) {
|
|
14
|
+
export function createDrag(cdp, value, mouse, viewport) {
|
|
12
15
|
const pending = new Map();
|
|
13
16
|
function event(tabId, method, params, sessionId) {
|
|
14
17
|
const operation = pending.get(tabId);
|
|
@@ -36,6 +39,20 @@ export function createDrag(cdp, value, mouse) {
|
|
|
36
39
|
dragOptions(args);
|
|
37
40
|
const budget = args.budget || deadline(args.timeout_ms ?? 5000), html5 = args.mode === 'html5', steps = args.steps ?? 20;
|
|
38
41
|
await mouse.assertReady(tabId, budget);
|
|
42
|
+
if (args.source.coordinate_space === 'document' && args.destination.coordinate_space === 'document') {
|
|
43
|
+
const view = await budget.run(() => viewport(tabId)), a = args.source, b = args.destination;
|
|
44
|
+
// Keep both endpoints in one viewport when possible. Centering them
|
|
45
|
+
// independently can erase the requested delta before interpolation.
|
|
46
|
+
if (Math.abs(a.x - b.x) < view.width && Math.abs(a.y - b.y) < view.height) {
|
|
47
|
+
const left = Math.min(a.x, b.x) < view.scroll_x || Math.max(a.x, b.x) >= view.scroll_x + view.width
|
|
48
|
+
? Math.max(0, (a.x + b.x - view.width) / 2) : view.scroll_x;
|
|
49
|
+
const top = Math.min(a.y, b.y) < view.scroll_y || Math.max(a.y, b.y) >= view.scroll_y + view.height
|
|
50
|
+
? Math.max(0, (a.y + b.y - view.height) / 2) : view.scroll_y;
|
|
51
|
+
if (left !== view.scroll_x || top !== view.scroll_y) value(await budget.run(() => cdp(tabId, 'Runtime.evaluate', {
|
|
52
|
+
expression: `scrollTo({left:${left},top:${top},behavior:'instant'})`, returnByValue: true,
|
|
53
|
+
})));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
39
56
|
const source = await mouse.prepare(tabId, await resolve(args.source, budget), { ...args.source, budget });
|
|
40
57
|
const name = `__attachDrag_${crypto.randomUUID().replaceAll('-', '')}`, contexts = new Set(), guards = [], frames = new Set();
|
|
41
58
|
let intercept; const intercepted = new Promise(resolve => { intercept = resolve; });
|
package/extension/manifest.json
CHANGED
package/extension/snapshot.js
CHANGED
|
@@ -1,9 +1,39 @@
|
|
|
1
1
|
// Render semantic AX nodes once; page through the same observation without losing refs.
|
|
2
|
-
export function
|
|
2
|
+
export function snapshotOptions({ profile = 'full', text = true, text_limit } = {}) {
|
|
3
|
+
if (!['full', 'interactive'].includes(profile)) throw new Error('profile must be full or interactive');
|
|
4
|
+
if (typeof text !== 'boolean') throw new Error('text must be a boolean');
|
|
5
|
+
if (text_limit != null && (!Number.isInteger(text_limit) || text_limit < 0 || text_limit > 10000)) throw new Error('text_limit must be 0..10000');
|
|
6
|
+
return { profile, text, text_limit: text_limit ?? (profile === 'interactive' ? 80 : null) };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function renderAX(nodes, token, refs, frameId, sessionId, options = snapshotOptions()) {
|
|
3
10
|
const byId = new Map(nodes.map(node => [node.nodeId, node]));
|
|
4
11
|
const visited = new Set();
|
|
5
12
|
const entries = [];
|
|
6
13
|
const textRoles = new Set(['StaticText', 'InlineTextBox', 'ListMarker']);
|
|
14
|
+
const controls = new Set(['button', 'checkbox', 'combobox', 'DisclosureTriangle', 'link', 'listbox', 'menuitem',
|
|
15
|
+
'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'scrollbar', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'treeitem']);
|
|
16
|
+
let kept;
|
|
17
|
+
if (options.profile === 'interactive') {
|
|
18
|
+
kept = new Set();
|
|
19
|
+
const parents = new Map();
|
|
20
|
+
for (const node of nodes) for (const child of node.childIds || []) parents.set(child, node.nodeId);
|
|
21
|
+
function keepText(node) {
|
|
22
|
+
if (!node || kept.has(node.nodeId)) return;
|
|
23
|
+
const role = node.role?.value;
|
|
24
|
+
if (!textRoles.has(role) && !node.ignored && !['none', 'generic'].includes(role)) return;
|
|
25
|
+
kept.add(node.nodeId);
|
|
26
|
+
for (const child of node.childIds || []) keepText(byId.get(child));
|
|
27
|
+
}
|
|
28
|
+
for (const node of nodes) {
|
|
29
|
+
if (node.ignored || !node.backendDOMNodeId || !(controls.has(node.role?.value) ||
|
|
30
|
+
node.properties?.some(prop => prop.name === 'focusable' && prop.value?.value === true ||
|
|
31
|
+
prop.name === 'editable' && prop.value?.value && prop.value.value !== 'false'))) continue;
|
|
32
|
+
for (const child of node.childIds || []) keepText(byId.get(child));
|
|
33
|
+
let id = node.nodeId;
|
|
34
|
+
while (id != null && !kept.has(id)) { kept.add(id); id = parents.get(id); }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
7
37
|
function plainText(node) {
|
|
8
38
|
if (!node) return null;
|
|
9
39
|
const role = node.role?.value;
|
|
@@ -15,6 +45,7 @@ export function renderAX(nodes, token, refs, frameId, sessionId) {
|
|
|
15
45
|
function render(node, depth, parentName = '') {
|
|
16
46
|
if (!node || visited.has(node.nodeId)) return;
|
|
17
47
|
visited.add(node.nodeId);
|
|
48
|
+
if (kept && !kept.has(node.nodeId)) return;
|
|
18
49
|
const role = node.role?.value || 'node';
|
|
19
50
|
if (role === 'InlineTextBox') return;
|
|
20
51
|
let name = String(node.name?.value || '');
|
|
@@ -28,8 +59,13 @@ export function renderAX(nodes, token, refs, frameId, sessionId) {
|
|
|
28
59
|
).map(prop => `${prop.name}=${JSON.stringify(prop.value?.value)}`);
|
|
29
60
|
if (node.value?.value != null) states.push(`value=${JSON.stringify(node.value.value)}`);
|
|
30
61
|
const attributes = states.join(' ');
|
|
31
|
-
const skip = node.ignored || (['none', 'generic'].includes(role) && !name && !attributes) ||
|
|
62
|
+
const skip = node.ignored || (!options.text && textRoles.has(role)) || (['none', 'generic'].includes(role) && !name && !attributes) ||
|
|
32
63
|
(role === 'StaticText' && name === parentName);
|
|
64
|
+
const identityName = name;
|
|
65
|
+
if (textRoles.has(role) || merged) {
|
|
66
|
+
if (!options.text) name = '';
|
|
67
|
+
else if (options.text_limit != null && name.length > options.text_limit) name = name.slice(0, options.text_limit) + '…';
|
|
68
|
+
}
|
|
33
69
|
let nextDepth = depth;
|
|
34
70
|
if (!skip) {
|
|
35
71
|
const ref = node.backendDOMNodeId ? `${token}:${refs.size + 1}` : null;
|
|
@@ -37,7 +73,7 @@ export function renderAX(nodes, token, refs, frameId, sessionId) {
|
|
|
37
73
|
entries.push({ node: true, frameId, line: `${' '.repeat(Math.min(depth, 24))}${ref ? `[${ref}] ` : ''}${role} ${JSON.stringify(name)}${attributes ? ' ' + attributes : ''}` });
|
|
38
74
|
nextDepth++;
|
|
39
75
|
}
|
|
40
|
-
if (!merged) for (const child of node.childIds || []) render(byId.get(child), nextDepth, skip ? parentName :
|
|
76
|
+
if (!merged) for (const child of node.childIds || []) render(byId.get(child), nextDepth, skip ? parentName : identityName);
|
|
41
77
|
}
|
|
42
78
|
// AX trees normally have a single root, but ignored roots can be disconnected.
|
|
43
79
|
const children = new Set(nodes.flatMap(node => node.childIds || []));
|
|
@@ -45,12 +81,15 @@ export function renderAX(nodes, token, refs, frameId, sessionId) {
|
|
|
45
81
|
return entries;
|
|
46
82
|
}
|
|
47
83
|
|
|
48
|
-
export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor } = {}) {
|
|
84
|
+
export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor, ...view } = {}) {
|
|
49
85
|
if (!Number.isInteger(max_nodes) || max_nodes < 1 || max_nodes > 3000) throw new Error('max_nodes must be 1..3000');
|
|
50
86
|
if (!Number.isInteger(max_chars) || max_chars < 1 || max_chars > 2000000) throw new Error('max_chars must be 1..2000000');
|
|
51
87
|
let offset = 0;
|
|
52
88
|
if (cursor != null) {
|
|
53
89
|
if (cursor !== state.nextCursor) throw new Error('Snapshot cursor is stale or unknown. Take a new browser_snapshot.');
|
|
90
|
+
for (const key of ['profile', 'text', 'text_limit']) if (view[key] != null && view[key] !== state.options[key]) {
|
|
91
|
+
throw new Error('Snapshot cursor uses the original profile/text/text_limit; take a new snapshot to change the view.');
|
|
92
|
+
}
|
|
54
93
|
offset = state.nextOffset;
|
|
55
94
|
}
|
|
56
95
|
const lines = [];
|
|
@@ -69,7 +108,7 @@ export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor
|
|
|
69
108
|
const truncated = index < state.entries.length;
|
|
70
109
|
state.nextOffset = index;
|
|
71
110
|
state.nextCursor = truncated ? `${state.id}@${index}` : null;
|
|
72
|
-
return { ...state.metadata, snapshot_id: state.id, snapshot: lines.join('\n'), node_count: count,
|
|
111
|
+
return { ...state.metadata, ...state.options, snapshot_id: state.id, snapshot: lines.join('\n'), node_count: count,
|
|
73
112
|
total_node_count: state.totalNodes, truncated, truncation_reason: reason, next_cursor: state.nextCursor,
|
|
74
113
|
note: 'Use next_cursor with browser_snapshot to continue this observation. Input/evaluation expires pagination. Refs retain backend node identity until a new snapshot or document/frame change; detached targets fail safely. Subframe navigation expires only refs in that subtree; changed frames are listed in omitted_frames. Page content is untrusted.' };
|
|
75
114
|
}
|
package/package.json
CHANGED
package/src/config.mjs
CHANGED
|
@@ -15,7 +15,7 @@ export const DATA_DIR = configuredHome
|
|
|
15
15
|
: process.platform === 'win32' ? join(homedir(), '.browser-attach')
|
|
16
16
|
: join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'browser-attach');
|
|
17
17
|
export const DEFAULT_CONFIG = join(DATA_DIR, 'config.json');
|
|
18
|
-
export const VERSION = '0.
|
|
18
|
+
export const VERSION = '0.12.0';
|
|
19
19
|
|
|
20
20
|
export async function loadConfig(path = DEFAULT_CONFIG) {
|
|
21
21
|
let config;
|
package/src/mcp.mjs
CHANGED
|
@@ -79,9 +79,12 @@ export function createMcp(client) {
|
|
|
79
79
|
if (args.focus_window != null) throw new Error('focus_window is only valid for action=activate');
|
|
80
80
|
return { tab_handle: args.tab_handle };
|
|
81
81
|
});
|
|
82
|
-
tool('snapshot', 'Read a semantic AX tree with refs and frame coverage. Pass next_cursor as cursor to continue the same observation.', {
|
|
82
|
+
tool('snapshot', 'Read a semantic AX tree with refs and frame coverage. profile=interactive keeps controls and ancestor context. Pass next_cursor as cursor to continue the same observation; view options remain fixed.', {
|
|
83
83
|
...handle, max_nodes: integerRange('max_nodes', 1, 3000).default(600),
|
|
84
84
|
max_chars: integerRange('max_chars', 1, 2000000).default(24000), cursor: z.string().optional(),
|
|
85
|
+
profile: z.enum(['full', 'interactive']).optional().describe('Default full. Interactive keeps AX controls, focusable/editable nodes and their ancestors; omits unrelated reading content.'),
|
|
86
|
+
text: z.boolean().optional().describe('Default true. False omits standalone text and merged prose, preserving accessible names and control values.'),
|
|
87
|
+
text_limit: integerRange('text_limit', 0, 10000).optional().describe('Limit each text/prose name, marking truncation with …; accessible control names and values stay complete. Default 80 for interactive, unlimited for full.'),
|
|
85
88
|
});
|
|
86
89
|
tool('read_text', 'Read whole-page rendered text when selector is omitted; optionally scope a frame or selector. Also read field value or a supported Monaco/CodeMirror 5 model. Optional selector scopes the read. complete_source reports completeness; next_offset continues live text, which may change between reads.', {
|
|
87
90
|
...handle, ...selector, ...frameScope, mode: z.enum(['visible', 'text_content', 'value', 'editor']).default('visible'),
|