@glittering0717/ai-browser 1.3.0 → 2.0.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.
@@ -0,0 +1,264 @@
1
+ // src/shared/doc_coverage.js — 「这棵树读到了多少页面文字」的度量
2
+ //
3
+ // ============ 为什么要有它 ============
4
+ // 2026-10-19 的泛雅事故里,默认语义树 27 个节点、13 个可操作,而整门课的章节
5
+ // 列表一个字都不在树里。当时没有任何一个数字能表达这件事:调用方看到的是
6
+ // "merged / frames_read:1",以及 capabilities 的一句"the UI is largely
7
+ // canvas-rendered"。**"读到了"从来不是一个可测量的结论。**
8
+ //
9
+ // 同一个仓库里已经有这个纪律,只是只覆盖了一半:tools/coverage_probe.cjs 数
10
+ // "人看着能点的东西有多少进了树"。但它数的是**可点目标**,而且原先只数默认
11
+ // 文档。文字呢?没人数。
12
+ //
13
+ // 这个模块补的就是那个数字:**树里的文字 / 页面上看得见的文字**。
14
+ // 分母 = 主文档 innerText 长度 + 每个跨进程帧里可见文字长度(帧那边由
15
+ // frame_read_source.stats.text_chars 给,已经算过,不重复付钱)。
16
+ //
17
+ // ============ 为什么用 innerText ============
18
+ // 它是**浏览器自己算出来的"看得见的文字"**:display:none、折叠的、被
19
+ // visibility:hidden 藏掉的都不算,换行/空白也按渲染结果归一。用它当分母,
20
+ // 比"数 DOM 里的 textContent"更接近人眼看到的东西 —— 也就更接近
21
+ // "agent 应该能读到多少"。
22
+ //
23
+ // 它是纯函数模块(不碰 DOM、不碰 Electron),所以能单测;
24
+ // 取 innerText 的那一侧在 preload/extractor.cjs 的 stats.text_chars。
25
+
26
+ /**
27
+ * 这些角色的 label 是**元数据**,不是页面上渲染出来的文字。
28
+ *
29
+ * 为什么必须排除:分母用 `innerText` 量(浏览器自己算的"看得见的字"),
30
+ * 分子就得用同一把尺子。实测泛雅页面上:
31
+ * - `<img alt="Logo">` 的 alt 不在 innerText 里;
32
+ * - `<iframe name="frame_content-zj">` 的 name 更不在 —— 而树的合并逻辑
33
+ * 会给同一个 iframe 建三个描述节点(iframe_body / iframe / Iframe 闸门),
34
+ * `frame_content-zj` 这 16 个字被数了三遍,凭空多出 32 字。
35
+ * 不排除它们,比例就会溢出到 100% 以上,这个数字就废了(实测 547/185)。
36
+ *
37
+ * 用 role 名单而不是"看有没有 children":children 数量是树形状的偶然结果,
38
+ * role 才是语义。
39
+ */
40
+ const META_ROLES = new Set(['image', 'img', 'iframe', 'iframe_body', 'canvas', 'separator']);
41
+
42
+ /**
43
+ * 这些 label **不是页面上渲染出来的文字**,而是可访问名:aria-label / alt /
44
+ * title / placeholder / input 的 value。抽取器给它们标了 label_kind。
45
+ *
46
+ * 为什么必须排除:分母是 innerText,里面根本没有这些字。实测 /basic.html ——
47
+ * `<input placeholder="随便填">` 让分子 46 大于分母 43,页面明明只有 43 字却报 100%。
48
+ * 真实站点上每个图标按钮都挂 aria-label,虚高是系统性的,不是零点几个百分点。
49
+ * (节点本身照旧带 label —— agent 需要它来认识"这个按钮是干什么的",只是不算进覆盖率。)
50
+ */
51
+ const isNonRenderedLabel = (n) => !!(n.label_kind && n.label_kind !== 'text');
52
+
53
+ /**
54
+ * 只数**非空白字符**。
55
+ *
56
+ * 为什么:分母来自 `innerText`,它会把结构分隔符也吐出来 —— 一个 32 格表格的
57
+ * innerText 里夹着 30 多个 `\t` / `\n`,而分子(label)没有这些。
58
+ * 实测一张 8×4 的表格:page 80 字、tree 54 字 → 覆盖率 68%,看起来"少读了三分之一",
59
+ * 实际每个单元格的文字都在,少的是**空格**。
60
+ * 中文页面几乎不吃这个亏,英文页面按词空格能虚低 15%,表格页面能虚低 40%。
61
+ * 同一把尺子量两边,这类结构性误差才会消失。
62
+ */
63
+ const countChars = (s) => {
64
+ if (!s) return 0;
65
+ const m = String(s).match(/[^\s\u00a0]/g);
66
+ return m ? m.length : 0;
67
+ };
68
+
69
+ /** 树里所有**正文** label 的字符数。图标的空 label、可编辑框的占位符不算。 */
70
+ export function sumLabelChars(tree) {
71
+ let labels = 0;
72
+ let nodes = 0;
73
+ let chars = 0;
74
+
75
+ // 累加规则:**先算后代,后代有字就以后代为准**。
76
+ //
77
+ // 为什么不是"把所有 label 加起来":DOM 抽取器会给容器节点也写 label,而且
78
+ // 那 label 就是它里面的全部文字(`<div>课程门户 链接</div>`)。全加一遍等于
79
+ // 同一句话数了三次 —— 实测泛雅页 tree_chars 547 vs 页面 185,覆盖率直接
80
+ // 溢出到 100%,这个数字就废了。
81
+ //
82
+ // 为什么不是"只数叶子":图标链接 `<a>课程门户<i/></a>` 有子节点(空的 i),
83
+ // 按叶子算会把它的文字丢掉 —— 那正是"少算",同样会让数字失真。
84
+ //
85
+ // 所以:有字的后代优先;后代一个字都没有时,才认这个节点自己的 label。
86
+ function measure(n) {
87
+ if (!n) return 0;
88
+ nodes += 1;
89
+ let kidsChars = 0;
90
+ for (const c of n.children || []) kidsChars += measure(c);
91
+ // 可编辑节点的 label 是 placeholder / value,不是页面正文:
92
+ // innerText 里没有它们,算进分子会让覆盖率虚高(一个带 placeholder 的
93
+ // 搜索框就能把 0% 抬到 20%)。
94
+ const role = typeof n.role === 'string' ? n.role.toLowerCase() : '';
95
+ const own = (n.editor_type || isNonRenderedLabel(n) || META_ROLES.has(role)) ? 0 : countChars(n.label);
96
+ if (kidsChars > 0) return kidsChars; // 后代有字:以它们为准,容器不再加一遍
97
+ if (own) labels += 1; // 这个节点是这段文字的唯一来源
98
+ return own;
99
+ }
100
+
101
+ chars = measure(tree);
102
+ return { chars, labels, nodes };
103
+ }
104
+
105
+ const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
106
+
107
+ /**
108
+ * 分子超过分母这么多,就不是"读得好",是**分母漏了一块**。
109
+ *
110
+ * 为什么要留这个判据:这个指标的第一版就在这里撒过谎。泛雅页分子 547、分母 185,
111
+ * 我当时的处理是钳到 1、当没事发生。可"分子大于分母"本身就是一个**测量结论**:
112
+ * 树里数出来的字比页面上能看见的还多,只可能是分母少统计了一个承载文字的
113
+ * 表面(同源子帧 / shadow DOM / canvas),或者是分子在重复计数。
114
+ * 钳掉之后,100% 这个数字看起来最健康,恰好把问题藏得最深。
115
+ */
116
+ export const OVER_MEASURED = 1.2;
117
+
118
+ /**
119
+ * 页面级文字覆盖率。
120
+ *
121
+ * @param {{treeChars:number, mainChars:number, sameOriginChars?:number,
122
+ * frameChars:number, canvases?:number, ax?:boolean}} input
123
+ * ax:true 表示这是 AX 树(label 是可访问名,不是渲染文字),
124
+ * 超过 100% 不算异常,见下面 over 分支。
125
+ * @returns {{tree_chars:number, page_chars:number, main_chars:number, frame_chars:number,
126
+ * same_origin_chars:number, ratio:number|null, percent:number|null,
127
+ * hint:string|null, unmeasurable?:string, over_measured?:boolean,
128
+ * names_exceed_text?:boolean}}
129
+ */
130
+ export function pageCoverage(input) {
131
+ const o = input && typeof input === 'object' ? input : {};
132
+ const tree = Math.max(0, Number(o.treeChars) || 0);
133
+ const main = Math.max(0, Number(o.mainChars) || 0);
134
+ const sameOrigin = Math.max(0, Number(o.sameOriginChars) || 0);
135
+ const shadow = Math.max(0, Number(o.shadowChars) || 0);
136
+ const frame = Math.max(0, Number(o.frameChars) || 0);
137
+ const canvases = Math.max(0, Number(o.canvases) || 0);
138
+ // 分母 = 分子会走到的每一个表面:主文档 + 同源子帧 + open shadow root + 跨进程帧。
139
+ // 少算任何一块,比例就虚高 —— 而同源子帧和 shadow DOM 是最容易漏的两块:
140
+ // `body.innerText` 既不下钻 iframe,也不穿过 shadow 边界,抽取器却两头都会走。
141
+ // 实测 shadow 页:树里 35 字、分母只有 8 字 → over_measured。
142
+ const page = main + sameOrigin + shadow + frame;
143
+ const base = {
144
+ tree_chars: tree,
145
+ page_chars: page,
146
+ main_chars: main,
147
+ frame_chars: frame,
148
+ same_origin_chars: sameOrigin,
149
+ shadow_chars: shadow,
150
+ ratio: null,
151
+ percent: null,
152
+ hint: null,
153
+ };
154
+ // 页面一个字都没有(空标签页、还没渲染)→ 不报覆盖率。
155
+ // 报 0 会被读成"我们没读到",而事实是"没有可读的文字"。两种情况必须分开。
156
+ if (page <= 0) {
157
+ if (canvases > 0) {
158
+ return { ...base, unmeasurable: 'canvas', hint: CANVAS_HINT };
159
+ }
160
+ return base;
161
+ }
162
+ // canvas 画的字永远不在 innerText 里,所以这类页面的"文字覆盖率"没有分母。
163
+ // 与其拿页面上那 4 个字的 HUD 文案算出一个 100%,不如直接说量不了。
164
+ if (canvases > 0 && page < 200) {
165
+ return { ...base, unmeasurable: 'canvas', hint: CANVAS_HINT };
166
+ }
167
+
168
+ const raw = tree / page;
169
+ const over = raw > OVER_MEASURED;
170
+ const ratio = clamp01(raw);
171
+ base.ratio = Math.round(ratio * 1000) / 1000;
172
+ base.percent = Math.round(ratio * 100);
173
+ if (over) {
174
+ // AX 树的 label 是**可访问名**,天生包含 aria-label / alt / title 这些
175
+ // 不属于 innerText 的字。所以 AX 读数超过 100% 是设计使然,不是"分母漏了表面"。
176
+ // 实测 知乎首页:DOM 346/443、AX 542/443 —— 那 99 个字是 18 个图标按钮的
177
+ // aria-label,一个字都没漏。这种情况不能报 over_measured(那是给 DOM 树用的
178
+ // 报警器,报了就是每张真实页面都报警,警报就废了),但也不许装作没发生:
179
+ // 记一个 names_exceed_text 让调用方知道"这个 100% 是钳出来的"。
180
+ if (o.ax) {
181
+ base.names_exceed_text = true;
182
+ return base;
183
+ }
184
+ base.over_measured = true;
185
+ base.hint = `the tree carries ${tree} chars but this page only has ${page} measurable visible chars `
186
+ + `(${main} main + ${sameOrigin} same-origin iframe + ${shadow} shadow DOM + ${frame} cross-process frame) — `
187
+ + 'the numerator is larger than the denominator, so the denominator is missing a surface '
188
+ + '(text inside shadow DOM, or a frame we could not measure). Treat this page\'s coverage as unknown, '
189
+ + 'not as 100%: read with get_tree {ax:true} and judge by the content';
190
+ return base;
191
+ }
192
+ base.hint = coverageHint(base);
193
+ return base;
194
+ }
195
+
196
+ const CANVAS_HINT = 'this page draws on a <canvas>, so its text is invisible to innerText and text coverage '
197
+ + 'cannot be measured — a percentage here would be meaningless. Use ui.screenshot to see it, '
198
+ + 'ui.canvas_read to get the pixels, or context.route in ui.capabilities to pick a read layer';
199
+
200
+ /**
201
+ * 低于这个比例才给提示。
202
+ *
203
+ * 0.5 是错的,实测被抓了三次(e2e/readcov_ws.cjs 第一次跑就三条一起红):
204
+ * · /long.html 70% —— 60 段正文全在树里,但每段 label 被裁到 60 字
205
+ * · /xorigin-frame 59% —— 帧合并了,帧里的段落同样被裁
206
+ * · /sr-only 27% —— 移出视口的 span 没有独立节点
207
+ * 这三张页面**都在静默地少读**(27% 是最刺眼的那种:页面 22 字,agent 只看到 6 字),
208
+ * 而闸门 0.5 让前两张一声不响。也就是说这个闸门对"读了一半"这种最常见、
209
+ * 也最容易误导人的状态恰好是聋的。
210
+ *
211
+ * 判据只能是**不变量本身**:读不到 90% 就该说一句。提示是一句话,多给一次的
212
+ * 成本远低于"agent 以为读完了"的成本。
213
+ */
214
+ export const LOW_COVERAGE = 0.9;
215
+
216
+ /**
217
+ * 绝对缺口的下限(字)。只负责"别为一两个字打扰调用方",不负责判断严重程度 ——
218
+ * 严重程度由比例闸门说。
219
+ *
220
+ * 这个数字的历史:最初是 `page_chars < 200 → 不提示`(把泛雅 185 字自己排除了),
221
+ * 后来是 20(把 /sr-only.html 的 73% 缺口静音了),现在 5。详见 missingEnough。
222
+ */
223
+ export const MIN_MISSING_CHARS = 5;
224
+
225
+ /**
226
+ * 这个页面"漏够了没有"。
227
+ *
228
+ * 这里换过三版判据,每一版都是被实测推翻的:
229
+ * 1. `page_chars < 200 → 静音`:把泛雅(185 字)自己排除了。
230
+ * 2. `缺 ≥ 20 字`:把 /sr-only.html(22 字页面漏 16 字,漏 73%)静音了。
231
+ * 3. 现在:比例闸门已经抬到 0.9,缺口天然是成比例的;绝对值只留一个
232
+ * "别为一两个字打扰调用方"的下限。
233
+ *
234
+ * 0.9 这条闸门意味着"漏 10% 以上就要说话",所以 20 这个绝对门槛是重复保险,
235
+ * 而它在小页面上反而成了静音器。留着 5:一个词以下的缺口,说什么都没用。
236
+ */
237
+ function missingEnough(missing) {
238
+ return missing >= MIN_MISSING_CHARS;
239
+ }
240
+
241
+ function coverageHint(c) {
242
+ if (c.ratio === null || c.ratio >= LOW_COVERAGE) return null;
243
+ const missing = Math.max(0, c.page_chars - c.tree_chars);
244
+ if (!missingEnough(missing)) return null;
245
+ // 归因要挂在数字上,不能靠猜。帧里有多少字是**实测**的(frame_read_source),
246
+ // 所以这里能分辨"文字在跨进程帧里"和"文字在主文档里但没进树"。
247
+ //
248
+ // 帧这一支**不许**推荐 ax:true:AX 树对子帧的内容同样走 frame_read_source
249
+ // 的合并通路(实测 /same-frame AX 也只能到 55%),指过去等于把人指进死胡同。
250
+ // 帧里文字的可靠读法是 frame_ref / 帧地址。
251
+ if (c.frame_chars > 0 && c.frame_chars >= missing * 0.6) {
252
+ return `the tree carries ${c.percent}% of this page's visible text (${c.tree_chars}/${c.page_chars} chars); `
253
+ + `${c.frame_chars} of the missing ${missing} chars are inside cross-process frame(s). `
254
+ + 'Frame bodies are merged as a preview (each block clipped), so read them directly: '
255
+ + 'ui.evaluate {frame_ref:"frame-1", expr:"document.body.innerText"} — the frame refs and absolute '
256
+ + 'src are in context.frame_hints.frames';
257
+ }
258
+ if (c.main_chars > 0 && c.main_chars >= missing * 0.6) {
259
+ return `the tree carries ${c.percent}% of this page's visible text (${c.tree_chars}/${c.page_chars} chars) and most of the gap is in the main document — the DOM tree keeps structure and clips each block's text, the AX tree keeps the full text. Try get_tree {ax:true} before concluding the content is invisible; if AX is also low, the page may still be rendering, or the content sits in shadow DOM / a virtualized list`;
260
+ }
261
+ return `the tree carries ${c.percent}% of this page's visible text (${c.tree_chars}/${c.page_chars} chars) — the gap is split between the main document and cross-process frames; check context.frame_hints first, and use frame_ref for the frames`;
262
+ }
263
+
264
+ export default { sumLabelChars, pageCoverage, LOW_COVERAGE, MIN_MISSING_CHARS, OVER_MEASURED };
@@ -0,0 +1,160 @@
1
+ // shared/effect_state.js — 每个动作回执都自带"页面到底被改动了没有"的三态判断。
2
+ //
3
+ // 为什么必须有这个字段(来自真实故障,不是设计洁癖):
4
+ // agent 点击后拿到 `{success:false, error:'...'}`,它无法回答一个关键问题 ——
5
+ // **刚才那一下,页面里到底发生没发生?**
6
+ // · 如果是"根本没点到"(target_not_found),重试是安全的;
7
+ // · 如果是"点到了但回执超时",重试就是第二次提交 —— 表单重复提交、消息发两遍、
8
+ // 订单下两次。这类事故在 agent 上比人更容易发生,因为 agent 没有"我记得我刚
9
+ // 点过"这层直觉。
10
+ // 以前这个判断藏在 error 字符串里,要靠模型读懂措辞("may have partially applied"
11
+ // 写在一个 30 个词的英文长句末尾,实测经常被忽略)。现在把它变成一个**机器可判
12
+ // 的枚举**,并配一条明确的重试策略。
13
+ //
14
+ // 三态定义(宁可保守,不可乐观):
15
+ // none —— 有证据表明页面**没被动过**(目标不存在、参数不合法、CDP 没连上、
16
+ // 形状体检没过)。重试安全。
17
+ // committed —— 输入已经派发且页面 ack 了。不要重试。
18
+ // unknown —— **无法证明**没被动过(超时、事件派发了但页面没消费、粘贴落地存疑、
19
+ // 按了一半的键)。必须先读页面状态再决定,绝不能盲目重试。
20
+ //
21
+ // 最后一条是这套东西的全部价值所在:默认值是 unknown,不是 none。分类不出来时
22
+ // 承认不知道,比猜"应该没点到"安全得多。
23
+
24
+ export const EFFECT_NONE = 'none';
25
+ export const EFFECT_COMMITTED = 'committed';
26
+ export const EFFECT_UNKNOWN = 'unknown';
27
+
28
+ // 重试策略枚举(跟着 effect_state 一起回,省得调用方自己去查表)
29
+ export const RETRY_SAFE = 'safe';
30
+ export const RETRY_NO = 'no';
31
+ export const RETRY_VERIFY_FIRST = 'verify_first';
32
+
33
+ // 「页面一定没被动过」的征兆。全部取自 page_manager.js 里真实存在过的 error 文案
34
+ // (见 tests/test_effect_state.js 的 E-* 用例,那里逐条钉住了真实字符串)。
35
+ const NO_EFFECT_PATTERNS = [
36
+ /target[_ ]not[_ ]found/i,
37
+ // 上面的正则同时覆盖两种真实写法:page_manager 的 `target_not_found:` 与
38
+ // src/preload/actions.cjs:34 的 `Target not found for ref:`。它们**不是同一
39
+ // 个词**,2026-10-19 真机回归时按前者写死就漏判了后者,差点把"没点到"误判成
40
+ // "不知道"(那会让 agent 对一次干净的失败也不敢重试)。
41
+ /^no target/i,
42
+ /^no url provided/i,
43
+ /^no link matches url/i,
44
+ /no-such-tab|tab not found/i,
45
+ /target_has_no_value/i,
46
+ /sensitive_value_not_readable/i,
47
+ /unsupported action/i,
48
+ /missing[-_ ](snapshot|target|param)/i,
49
+ /unknown-snapshot|tab-mismatch/i,
50
+ /cdp-unavailable/i,
51
+ /requires CDP/i,
52
+ /^wheel: need /i,
53
+ /^drag: (start element not found|to_target not found|need one of)/i,
54
+ /upload target not found/i,
55
+ /cannot read file/i,
56
+ /^type called with empty text/i,
57
+ /^paste called with neither/i,
58
+ /snapshot_failed/i,
59
+ /ref_not_an_id|looks like visible text|not a data-ai-id/i,
60
+ /element_not_found|element is not visible|has zero size/i,
61
+ ];
62
+
63
+ // 「已经派发,但结果不确定」的征兆。顺序很重要:先判 partial,再判 no-effect,
64
+ // 因为 paste 的失败判断里同时出现 "not found" 与 "not consumed",前者是入口检查后者
65
+ // 是事后存疑,而**事后**才是真相。
66
+ const PARTIAL_PATTERNS = [
67
+ /may have partially applied/i,
68
+ /not consumed by the page/i,
69
+ /consumed but nothing landed/i,
70
+ /interrupted|disconnected|target closed|debugger detached/i,
71
+ /deadline exceeded|timed? ?out/i,
72
+ ];
73
+
74
+ function matches(patterns, text) {
75
+ for (const re of patterns) if (re.test(text)) return true;
76
+ return false;
77
+ }
78
+
79
+ /**
80
+ * 这条错误是不是"目标找不到"?ref 过期诊断只在这种失败上做,别的一律无关。
81
+ * 单独导出是因为 page_manager 那边要用**同一套判定**,不能两处各写一份正则
82
+ * (两份词表迟早分叉,实际已经分叉过一次:page_manager 用 `target_not_found:`,
83
+ * preload 用 `Target not found for ref:`)。
84
+ */
85
+ export function isTargetMiss(error) {
86
+ return typeof error === 'string' && /target[_ ]not[_ ]found/i.test(error);
87
+ }
88
+
89
+ /**
90
+ * 判定一个动作回执的 effect_state。
91
+ *
92
+ * @param {object} result page_manager 里各种动作返回的对象
93
+ * @returns {'none'|'committed'|'unknown'}
94
+ */
95
+ export function classifyEffect(result) {
96
+ if (!result || typeof result !== 'object') return EFFECT_UNKNOWN;
97
+
98
+ // 1. 超时是最高优先级:它可能已经生效,且**一定**不该被当成"没点到"。
99
+ if (result.timed_out === true) return EFFECT_UNKNOWN;
100
+
101
+ // 2. 明确成功 —— 动作实现里所有成功路径都在 CDP 派发之后才返回 success:true。
102
+ if (result.success === true) return EFFECT_COMMITTED;
103
+
104
+ // 2b. hover/peek 这条路径用 revealed 数组表达成功,没有 success 字段。
105
+ if (Array.isArray(result.revealed) && !result.error) return EFFECT_COMMITTED;
106
+
107
+ // 3. 按键按到一半失败:已经发出去的键**已经生效了**,后面那半没有。
108
+ if (Array.isArray(result.pressed) && result.pressed.length > 0) return EFFECT_UNKNOWN;
109
+
110
+ const errText = [result.error, result.hint, result.message]
111
+ .filter((x) => typeof x === 'string')
112
+ .join(' | ');
113
+
114
+ // 4. 事后存疑优先于入口检查。
115
+ if (matches(PARTIAL_PATTERNS, errText)) return EFFECT_UNKNOWN;
116
+
117
+ // 5. 有证据表明没被动过。
118
+ if (matches(NO_EFFECT_PATTERNS, errText)) return EFFECT_NONE;
119
+
120
+ // 6. 失败但没有证据 —— 承认不知道。
121
+ return EFFECT_UNKNOWN;
122
+ }
123
+
124
+ // 三态 → 重试策略。
125
+ //
126
+ // ⚠️ `retry` 回答的不是"有没有副作用",而是**"原样再来一次有没有意义"**。
127
+ // 这两件事会分叉,实测踩过:目标 ref 过期时 effect_state=none(确实什么都没发生,
128
+ // 重试没有副作用),但原样重试**必然再失败一次** —— 那时给 `safe` 就是在骗调用方。
129
+ // 所以 ref 类失败由调用方在 extras 里把 retry 覆盖成 verify_first。
130
+ export function retryAdvice(state) {
131
+ if (state === EFFECT_NONE) return RETRY_SAFE;
132
+ if (state === EFFECT_COMMITTED) return RETRY_NO;
133
+ return RETRY_VERIFY_FIRST;
134
+ }
135
+
136
+ // unknown 时随回执给一句**可执行**的话。它必须自解释 —— 模型第一次看到这个字段
137
+ // 也要能立刻知道该干什么,不然就得回去读文档,那正是我们要消灭的成本。
138
+ const HINTS = {
139
+ [EFFECT_NONE]: null,
140
+ [EFFECT_COMMITTED]: null,
141
+ [EFFECT_UNKNOWN]:
142
+ 'effect unknown: the page may have partially applied this action. Read the page (get_tree / get_value) to see its current state before trying again — a blind retry can submit twice.',
143
+ };
144
+
145
+ /**
146
+ * 把三态写进回执。纯函数,返回新对象(不原地改调用方的 result)。
147
+ *
148
+ * @param {object} result
149
+ * @param {object} [extras] 额外要合并进去的字段(如 stale_ref 诊断)
150
+ */
151
+ export function withEffectState(result, extras) {
152
+ if (!result || typeof result !== 'object') return result;
153
+ const state = classifyEffect(result);
154
+ const out = { ...result, effect_state: state, retry: retryAdvice(state), ...(extras || {}) };
155
+ const hint = HINTS[state];
156
+ // 已经有更具体的错误说明时不覆盖,只在 unknown 且没有说明时补一句 ——
157
+ // 保证 agent 一定能看到"先验证再重试"这条要求。
158
+ if (hint && !result.effect_hint) out.effect_hint = hint;
159
+ return out;
160
+ }
@@ -40,15 +40,75 @@ function shouldAutoAx(summary) {
40
40
  return !!(summary && typeof summary.visible_count === 'number' && summary.visible_count > 0);
41
41
  }
42
42
 
43
+ /**
44
+ * 把"读了几个帧、并进去多少东西、帧里还有多少字没进树"算成可核对的数字。
45
+ *
46
+ * 存在的理由(泛雅事故):describeAutoAx 的 `merged` 判据是
47
+ * `frames.length > 0` —— 也就是"我确实拼进去了一些节点"。实测那个页面上拼进
48
+ * 去的只有一个搜索框,正文(章节列表)一个字都没有,回执却是
49
+ * `{auto_ax:'merged', frames_read:1}`。调用方看到 merged 会认为"内容读到了",
50
+ * 于是一路往下操作,拿到的全是 target_not_found。
51
+ *
52
+ * "我试过了"和"成功了"必须能分开。这里产出三个测量值:
53
+ * nodes_added —— 真的并进树里的节点数(可以说 1)
54
+ * text_coverage —— 帧里看得见的文字有多少进了树(0.01 一眼就知道不对)
55
+ * frames[].src —— 帧的绝对地址,树里读不到时唯一的可靠退路
56
+ *
57
+ * @param {Array<{key:string,label?:string,src?:string,tree?:object,stats?:object}>} frames
58
+ */
59
+ function summarizeFrameRead(frames) {
60
+ const list = (Array.isArray(frames) ? frames : []).filter((f) => f && f.key);
61
+ if (!list.length) {
62
+ return { frames_read: 0, nodes_added: 0, text_coverage: null, frames: [] };
63
+ }
64
+ let nodesAdded = 0;
65
+ let chars = 0;
66
+ let keptChars = 0;
67
+ const per = [];
68
+ for (const f of list) {
69
+ const st = f.stats && typeof f.stats === 'object' ? f.stats : {};
70
+ const nodes = f.tree && Array.isArray(f.tree.children) ? f.tree.children.length : 0;
71
+ nodesAdded += nodes;
72
+ const c = Number(st.text_chars) || 0;
73
+ const k = Number(st.kept_chars) || 0;
74
+ chars += c;
75
+ keptChars += k;
76
+ per.push({
77
+ ref: f.key,
78
+ src: f.src || null,
79
+ nodes,
80
+ // 帧里可见文本的字符数:0 表示这一帧本身是空的/纯 widgets,不是我们漏了。
81
+ chars: c,
82
+ });
83
+ }
84
+ // 帧里一个字都没有时不报覆盖率 —— 报 0 会被读成"我们没读到",而事实是
85
+ // "这帧没有可读的文字"。两种情况必须分开。
86
+ const coverage = chars > 0 ? Math.round(Math.min(1, keptChars / chars) * 100) / 100 : null;
87
+ return { frames_read: list.length, nodes_added: nodesAdded, text_coverage: coverage, frames: per };
88
+ }
89
+
43
90
  /**
44
91
  * 自动 AX 读取的结果如何写进 context.frame_hints。
45
92
  * 三种结局分开说:合并成功 / 合并没有新内容 / AX 本身失败。调用方需要能
46
93
  * 区分"已经尽力了"和"还有一条没走过的路"。
94
+ *
95
+ * 另外附带 summarizeFrameRead 的测量值(能算多少算多少):`merged` 只说
96
+ * "拼进去了",`nodes_added` 与 `text_coverage` 才说"拼进去的是什么"。
47
97
  */
48
98
  function describeAutoAx(outcome) {
49
99
  const o = outcome && typeof outcome === 'object' ? outcome : {};
50
100
  if (o.merged) {
51
- return { auto_ax: 'merged', frames_read: Number(o.framesRead) || 0 };
101
+ const h = { auto_ax: 'merged', frames_read: Number(o.framesRead) || 0 };
102
+ const s = o.summary && typeof o.summary === 'object' ? o.summary : null;
103
+ if (s) {
104
+ if (typeof s.nodes_added === 'number') h.nodes_added = s.nodes_added;
105
+ if (s.text_coverage != null) h.text_coverage = s.text_coverage;
106
+ if (Array.isArray(s.frames) && s.frames.length) {
107
+ // 只留 ref/src —— 调用方要的是"去哪读剩下那部分",不是统计表。
108
+ h.frames = s.frames.map((f) => ({ ref: f.ref, src: f.src }));
109
+ }
110
+ }
111
+ return h;
52
112
  }
53
113
  if (o.error) {
54
114
  return { auto_ax: 'failed', reason: String(o.error) };
@@ -104,9 +164,39 @@ function mergeFrameContext(ctx, frames) {
104
164
  return out;
105
165
  }
106
166
 
167
+ /**
168
+ * `frame-N` → 子帧序号 N。不是门节点 ref 时返回 null。
169
+ *
170
+ * 为什么需要:axExtractor.mergeFrameTrees 把每个跨进程帧挂成一个 gate 节点,
171
+ * id 就是 `frame-N`。它在页面里**没有对应元素**,所以"按 data-ai-id 找元素"
172
+ * 这条路对它永远失败 —— 实测 `ui.evaluate {frame_ref:"frame-1"}` 报
173
+ * frame_not_found,而语义树里明明写着 frame-1。树里出现的 ref 必须能用。
174
+ */
175
+ function gateFrameIndex(aiId) {
176
+ const m = /^frame-(\d+)$/.exec(String(aiId == null ? '' : aiId));
177
+ if (!m) return null;
178
+ const n = Number(m[1]);
179
+ return Number.isInteger(n) && n >= 1 ? n : null;
180
+ }
181
+
182
+ /**
183
+ * 按 `frame-N` 从**子帧列表**(已排除主帧、保持 framesInSubtree 顺序)里取帧。
184
+ * 序号口径必须与 _readOopifFrames 完全一致:第 N 个非主帧,且读不到的帧也占号
185
+ * —— 两边不同步的话,`frame-2` 会指向另一个帧,比"找不到"更危险。
186
+ */
187
+ function pickSubframe(subs, aiId) {
188
+ const idx = gateFrameIndex(aiId);
189
+ if (idx == null) return null;
190
+ const list = Array.isArray(subs) ? subs : [];
191
+ return list[idx - 1] || null;
192
+ }
193
+
107
194
  module.exports = {
108
195
  summarizeOpaqueFrames,
109
196
  shouldAutoAx,
110
197
  describeAutoAx,
198
+ summarizeFrameRead,
111
199
  mergeFrameContext,
200
+ gateFrameIndex,
201
+ pickSubframe,
112
202
  };