@wudao-org/browser 0.0.0-stage → 0.4.20
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/LICENSE +21 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/download.d.ts +61 -0
- package/dist/download.d.ts.map +1 -0
- package/dist/download.js +217 -0
- package/dist/download.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/port.d.ts +141 -0
- package/dist/port.d.ts.map +1 -0
- package/dist/port.js +145 -0
- package/dist/port.js.map +1 -0
- package/dist/runtime.d.ts +92 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +188 -0
- package/dist/runtime.js.map +1 -0
- package/dist/snapshot.d.ts +58 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +121 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/tools.d.ts +29 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +678 -0
- package/dist/tools.js.map +1 -0
- package/package.json +33 -3
- package/README.md +0 -3
package/dist/tools.js
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
import { COLLECT_SCRIPT, formatSnapshot, validRef } from "./snapshot.js";
|
|
2
|
+
const ok = (content) => ({ content });
|
|
3
|
+
const err = (content) => ({ content, isError: true });
|
|
4
|
+
const msg = (e) => (e instanceof Error ? e.message : String(e));
|
|
5
|
+
const NOT_OPEN = "The browser window is not open — call browser_open with an http(s) URL first.";
|
|
6
|
+
/** Read the IHDR width/height out of PNG bytes (big-endian at fixed offsets
|
|
7
|
+
* 16/20 — the first chunk of every valid PNG is IHDR). Pure, no deps; used
|
|
8
|
+
* only to hand the sink honest dimensions. */
|
|
9
|
+
function pngSize(b) {
|
|
10
|
+
if (b.length < 24)
|
|
11
|
+
return null;
|
|
12
|
+
const w = (b[16] << 24) | (b[17] << 16) | (b[18] << 8) | b[19];
|
|
13
|
+
const h = (b[20] << 24) | (b[21] << 16) | (b[22] << 8) | b[23];
|
|
14
|
+
if (w <= 0 || h <= 0)
|
|
15
|
+
return null;
|
|
16
|
+
return { widthPx: w, heightPx: h };
|
|
17
|
+
}
|
|
18
|
+
/** ω99-114 fail-closed credential gate: the model NEVER types passwords.
|
|
19
|
+
* Literal-pinned by tests ("password" + "visible browser window"). */
|
|
20
|
+
const PASSWORD_REFUSED = (ref) => `Typing into ${ref} was refused: it is a password field. Never type a password for the user — ask the user to type their password in the visible browser window. The login persists in the controlled profile, so the site stays logged in for future sessions.`;
|
|
21
|
+
function parseHttpUrl(raw) {
|
|
22
|
+
if (typeof raw !== "string" || raw.trim() === "")
|
|
23
|
+
return null;
|
|
24
|
+
try {
|
|
25
|
+
const u = new URL(raw.trim());
|
|
26
|
+
return u.protocol === "http:" || u.protocol === "https:" ? u : null;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** ω99-114: the backoff between a failed goto and its single retry. */
|
|
33
|
+
const OPEN_RETRY_MS = 2_000;
|
|
34
|
+
/** ω99-114: navigate with ONE retry. Any goto failure retries once after a
|
|
35
|
+
* 2s backoff — transient DNS/socket blips recover, and the error-class
|
|
36
|
+
* strings are unstable, so the gate is deliberately WIDE (取宽): worst
|
|
37
|
+
* case one wasted 2s wait; never a retry storm. A second failure
|
|
38
|
+
* propagates and surfaces the existing honest error. The success path
|
|
39
|
+
* sleeps zero. */
|
|
40
|
+
async function gotoWithRetry(page, url) {
|
|
41
|
+
try {
|
|
42
|
+
return await page.goto(url);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
await new Promise((resolve) => setTimeout(resolve, OPEN_RETRY_MS));
|
|
46
|
+
return await page.goto(url);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function actionTarget(runtime, rawRef) {
|
|
50
|
+
if (runtime.state() === "closed")
|
|
51
|
+
return { kind: "err", result: err(NOT_OPEN) };
|
|
52
|
+
if (typeof rawRef !== "string" || !validRef(rawRef)) {
|
|
53
|
+
// ω99-144: browser_see 同刷 ref 表(138 语义),降级文案并列路由。
|
|
54
|
+
return { kind: "err", result: err(`ref must look like "e1", "e2", … (from browser_snapshot or browser_see); got ${JSON.stringify(rawRef)}.`) };
|
|
55
|
+
}
|
|
56
|
+
const snapshot = runtime.currentSnapshot();
|
|
57
|
+
if (snapshot === null) {
|
|
58
|
+
// ω99-144: browser_see 同刷 ref 表(138 语义),降级文案并列路由。
|
|
59
|
+
return { kind: "err", result: err("No snapshot yet — call browser_see (or browser_snapshot) first: both list every clickable element with its ref; browser_see also attaches the page image.") };
|
|
60
|
+
}
|
|
61
|
+
const entry = snapshot.refs.get(rawRef);
|
|
62
|
+
if (entry === undefined) {
|
|
63
|
+
// ω99-144: browser_see 同刷 ref 表(138 语义);ref 丢失现场是模型兜圈处,canvas 出路只在这里给。
|
|
64
|
+
return { kind: "err", result: err(`Ref ${rawRef} is not in the current snapshot (the page may have navigated since it was taken) — call browser_see or browser_snapshot again for fresh refs — if the table stays sparse (canvas/graphics page), use browser_see's image and click at CSS-pixel coordinates (browser_click_xy).`) };
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const page = await runtime.openPage();
|
|
68
|
+
// Stale guard at action time: the page may have navigated since the
|
|
69
|
+
// snapshot (link clicks, redirects, timers) — a ref from another
|
|
70
|
+
// document must never receive this click.
|
|
71
|
+
const now = await page.url();
|
|
72
|
+
if (now !== snapshot.url) {
|
|
73
|
+
runtime.invalidateSnapshot();
|
|
74
|
+
// ω99-144: browser_see 同刷 ref 表(138 语义),降级文案并列路由。
|
|
75
|
+
return { kind: "err", result: err(`The snapshot is stale — the page navigated to ${now} after it was taken. Call browser_see or browser_snapshot again and use the fresh refs.`) };
|
|
76
|
+
}
|
|
77
|
+
return { kind: "ok", page, snapshot, selector: entry.selector, desc: entry.desc, entry };
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
return { kind: "err", result: err(msg(e)) };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Poll for a post-action navigation (a form submit lands a tick after the
|
|
84
|
+
* keypress; a link's navigation after the click). Returns the final URL. */
|
|
85
|
+
async function settleUrl(page, before, ms = 1500) {
|
|
86
|
+
const start = Date.now();
|
|
87
|
+
let now = await page.url();
|
|
88
|
+
while (now === before && Date.now() - start < ms) {
|
|
89
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
90
|
+
now = await page.url();
|
|
91
|
+
}
|
|
92
|
+
return now;
|
|
93
|
+
}
|
|
94
|
+
/** After an action: void refs when the page moved; say which it was.
|
|
95
|
+
* settle=true (click, submit-Enter) polls briefly for the navigation the
|
|
96
|
+
* action itself initiates. */
|
|
97
|
+
async function navigationNote(runtime, page, before, settle = false) {
|
|
98
|
+
const now = settle ? await settleUrl(page, before) : await page.url();
|
|
99
|
+
runtime.noteUrl(now);
|
|
100
|
+
// ω99-144: browser_see 同刷 ref 表(138 语义),降级文案并列路由。
|
|
101
|
+
return now === before
|
|
102
|
+
? ""
|
|
103
|
+
: ` The page navigated to ${now} — all refs are invalidated; run browser_see or browser_snapshot for fresh refs.`;
|
|
104
|
+
}
|
|
105
|
+
// ω99-139 — the viewport probe for browser_click_xy's fallback layer: CSS
|
|
106
|
+
// pixels, the same grid as the bboxes (a fixed expression string so tests can
|
|
107
|
+
// match it deterministically).
|
|
108
|
+
const VIEWPORT_PROBE = "({ w: window.innerWidth, h: window.innerHeight })";
|
|
109
|
+
/** ω99-139 — pure hit-test: the SMALLEST-area ref'd bbox containing the
|
|
110
|
+
* point (the innermost box wins when elements nest). Inclusive edges;
|
|
111
|
+
* entries without a bbox never match (geometry not collected is never
|
|
112
|
+
* guessed). Iteration order is the refs map's insertion order, so ties
|
|
113
|
+
* keep the first-collected (document order) — deterministic. */
|
|
114
|
+
function hitRef(snapshot, x, y) {
|
|
115
|
+
let best = null;
|
|
116
|
+
for (const [ref, entry] of snapshot.refs) {
|
|
117
|
+
const b = entry.bbox;
|
|
118
|
+
if (b === undefined)
|
|
119
|
+
continue;
|
|
120
|
+
if (x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height) {
|
|
121
|
+
const area = b.width * b.height;
|
|
122
|
+
if (best === null || area < best.area)
|
|
123
|
+
best = { ref, area };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return best;
|
|
127
|
+
}
|
|
128
|
+
/** The eleven tools, in their FIXED registration order (the CLI inserts them
|
|
129
|
+
* at one fixed position; this order inside the group never changes).
|
|
130
|
+
* ω99-138: `imageSink` is optional — absent (or undefined-returning) keeps
|
|
131
|
+
* browser_see on its honest text-table degradation; the six 112 tools are
|
|
132
|
+
* untouched by it either way.
|
|
133
|
+
* ω99-139: browser_click_xy sits right after browser_click (the click pair),
|
|
134
|
+
* arming "point at pixels → click" for canvas pages browser_see unveiled.
|
|
135
|
+
* ω99-140: browser_type_xy sits right after browser_type (the type pair) —
|
|
136
|
+
* the same pairing logic, isomorphic: "point at pixels → type".
|
|
137
|
+
* ω99-141: browser_scroll_xy sits right after browser_scroll (the scroll
|
|
138
|
+
* pair) — "point at pixels → scroll the container under them".
|
|
139
|
+
* ω99-142: browser_downloads sits right after browser_scroll_xy, before
|
|
140
|
+
* browser_close — the export flow's last mile (see the file the page
|
|
141
|
+
* saved), zero parameters by design. */
|
|
142
|
+
export function browserToolsFor(runtime, imageSink) {
|
|
143
|
+
const browserOpen = {
|
|
144
|
+
name: "browser_open",
|
|
145
|
+
description: "Open a URL in the visible browser window (launching the browser if needed) and return the page title, the final URL after redirects, and a short text excerpt. http(s) URLs only. Follow with browser_snapshot to see the page's clickable elements and their refs.",
|
|
146
|
+
parameters: { type: "object", properties: { url: { type: "string", description: "Absolute http(s) URL to open." } }, required: ["url"], additionalProperties: false },
|
|
147
|
+
isReadOnly: () => false,
|
|
148
|
+
isConcurrencySafe: () => false,
|
|
149
|
+
shouldDefer: () => false,
|
|
150
|
+
isOpenWorld: () => true,
|
|
151
|
+
async execute(input) {
|
|
152
|
+
const url = parseHttpUrl(input["url"]);
|
|
153
|
+
if (url === null)
|
|
154
|
+
return err(`url must be an absolute http(s) URL; got ${JSON.stringify(input["url"])}.`);
|
|
155
|
+
let page;
|
|
156
|
+
try {
|
|
157
|
+
page = await runtime.openPage();
|
|
158
|
+
const { finalUrl } = await gotoWithRetry(page, url.toString());
|
|
159
|
+
const title = await page.title();
|
|
160
|
+
const text = (await page.text()).replace(/\s+/g, " ").trim();
|
|
161
|
+
runtime.invalidateSnapshot(); // a new document voids every ref
|
|
162
|
+
const excerpt = text.length > 500 ? `${text.slice(0, 500)}…` : text;
|
|
163
|
+
return ok(`Opened: ${finalUrl}\nTitle: ${title}\n\n${excerpt}\n\n(Next: browser_snapshot lists clickable elements with refs.)`);
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
return err(msg(e));
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
const browserSnapshot = {
|
|
171
|
+
name: "browser_snapshot",
|
|
172
|
+
description: "Capture the current page: its text outline plus every visible clickable element, each addressable by a short ref (e1, e2, …). Refs stay valid only until the page navigates — after any navigation or stale-ref error, take a fresh snapshot and use the new refs.",
|
|
173
|
+
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
174
|
+
isReadOnly: () => true,
|
|
175
|
+
isConcurrencySafe: () => false,
|
|
176
|
+
shouldDefer: () => false,
|
|
177
|
+
isOpenWorld: () => true,
|
|
178
|
+
async execute() {
|
|
179
|
+
if (runtime.state() === "closed")
|
|
180
|
+
return err(NOT_OPEN);
|
|
181
|
+
try {
|
|
182
|
+
const page = await runtime.openPage();
|
|
183
|
+
const collected = await page.evaluate(COLLECT_SCRIPT);
|
|
184
|
+
if (typeof collected !== "object" || collected === null || !Array.isArray(collected.elements)) {
|
|
185
|
+
return err("The page snapshot came back malformed — retry browser_snapshot.");
|
|
186
|
+
}
|
|
187
|
+
const payload = collected;
|
|
188
|
+
const { text, refs } = formatSnapshot({
|
|
189
|
+
url: typeof payload.url === "string" ? payload.url : "",
|
|
190
|
+
title: typeof payload.title === "string" ? payload.title : "",
|
|
191
|
+
text: typeof payload.text === "string" ? payload.text : "",
|
|
192
|
+
elements: payload.elements,
|
|
193
|
+
});
|
|
194
|
+
const url = typeof payload.url === "string" ? payload.url : "";
|
|
195
|
+
runtime.saveSnapshot(url, refs);
|
|
196
|
+
runtime.noteUrl(await page.url());
|
|
197
|
+
return ok(text);
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
return err(msg(e));
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
// ω99-138 — the vision face. Screenshot FIRST (the pixels the model is
|
|
205
|
+
// about to be shown), then the same collect/format/save channel as
|
|
206
|
+
// browser_snapshot, so the ref/bbox table and the image describe one page
|
|
207
|
+
// state and the navigation-void discipline is identical by construction.
|
|
208
|
+
const browserSee = {
|
|
209
|
+
name: "browser_see",
|
|
210
|
+
description: "Look at the current page: attach a screenshot (CSS pixel space — the same grid as the @[x,y,w,h] coordinates in snapshot lines) alongside the fresh clickable-element table with refs. Use it when visuals matter (canvas, images, layout, verifying what a change looks like). On text-only models the screenshot is omitted and the table alone is returned. Refreshes refs exactly like browser_snapshot.",
|
|
211
|
+
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
212
|
+
isReadOnly: () => true,
|
|
213
|
+
isConcurrencySafe: () => false,
|
|
214
|
+
shouldDefer: () => false,
|
|
215
|
+
isOpenWorld: () => true,
|
|
216
|
+
async execute() {
|
|
217
|
+
if (runtime.state() === "closed")
|
|
218
|
+
return err(NOT_OPEN);
|
|
219
|
+
try {
|
|
220
|
+
const page = await runtime.openPage();
|
|
221
|
+
// CSS-pixel capture: the DPR gap is normalized at the source (port.ts),
|
|
222
|
+
// so the PNG and the bbox table share one grid — no conversion ever.
|
|
223
|
+
const png = await page.screenshot({ scale: "css" });
|
|
224
|
+
const collected = await page.evaluate(COLLECT_SCRIPT);
|
|
225
|
+
if (typeof collected !== "object" || collected === null || !Array.isArray(collected.elements)) {
|
|
226
|
+
return err("The page snapshot came back malformed — retry browser_see.");
|
|
227
|
+
}
|
|
228
|
+
const payload = collected;
|
|
229
|
+
const { text, refs } = formatSnapshot({
|
|
230
|
+
url: typeof payload.url === "string" ? payload.url : "",
|
|
231
|
+
title: typeof payload.title === "string" ? payload.title : "",
|
|
232
|
+
text: typeof payload.text === "string" ? payload.text : "",
|
|
233
|
+
elements: payload.elements,
|
|
234
|
+
});
|
|
235
|
+
runtime.saveSnapshot(typeof payload.url === "string" ? payload.url : "", refs);
|
|
236
|
+
runtime.noteUrl(await page.url());
|
|
237
|
+
if (imageSink === undefined) {
|
|
238
|
+
// Honest degradation, no sink wired: the tool still works — the
|
|
239
|
+
// table is the complete actionable surface for a text model.
|
|
240
|
+
return ok(`${text}\n\n(视觉通道未接入:截图未附。上表已列出全部可点元素及其 CSS 像素坐标,可凭表操作;需要看图时请在接入视觉模型的会话中使用本工具。)`);
|
|
241
|
+
}
|
|
242
|
+
const meta = pngSize(png);
|
|
243
|
+
if (meta === null) {
|
|
244
|
+
return err("The screenshot bytes came back malformed (not a decodable PNG) — retry browser_see.");
|
|
245
|
+
}
|
|
246
|
+
let block;
|
|
247
|
+
try {
|
|
248
|
+
block = await imageSink(png, meta);
|
|
249
|
+
}
|
|
250
|
+
catch (e) {
|
|
251
|
+
// A failed attach is never a silent empty result (136 discipline):
|
|
252
|
+
// the error names the failure; the table above is NOT returned in
|
|
253
|
+
// the error channel — retry browser_see re-runs both halves.
|
|
254
|
+
return err(`Attaching the screenshot failed (${msg(e)}). The page has not changed; retry browser_see.`);
|
|
255
|
+
}
|
|
256
|
+
if (block === undefined) {
|
|
257
|
+
// The sink declined THIS call (the caller's modality pre-check —
|
|
258
|
+
// a text main model must never be fed an image-bearing request).
|
|
259
|
+
return ok(`${text}\n\n(截图未附:当前主模型不接受图像输入。上表已列出全部可点元素及其 CSS 像素坐标,可凭表完成操作。)`);
|
|
260
|
+
}
|
|
261
|
+
return { content: `${text}\n\n截图已附(CSS 像素空间,坐标即表中所列)。`, images: [block] };
|
|
262
|
+
}
|
|
263
|
+
catch (e) {
|
|
264
|
+
return err(msg(e));
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
// ω99-139 — the eighth tool: click CSS-pixel coordinates from browser_see.
|
|
269
|
+
// TWO LAYERS (the M-CU1 closing ruling): a point inside a ref'd element's
|
|
270
|
+
// bbox routes through actionTarget — the FULL browser_click channel (stale
|
|
271
|
+
// guard, selector click, navigation note, identical ask posture); a point
|
|
272
|
+
// that hits no bbox (canvas, an unref'd region) clicks the page at raw
|
|
273
|
+
// coordinates via clickAt. The miss layer's ask posture is whole-tool-class
|
|
274
|
+
// (the tool name sits in the CLI's DEFAULT_ASK_TOOLS): the hit layer being
|
|
275
|
+
// asked too is an accepted over-strictness — no parameter splitting.
|
|
276
|
+
const browserClickXy = {
|
|
277
|
+
name: "browser_click_xy",
|
|
278
|
+
description: "Click at CSS-pixel page coordinates (x, y) — the same grid as browser_see's screenshot and its @[x,y,w,h] table. A point inside a listed element's box clicks that element by ref (the normal click channel); a point outside every box (canvas, unref'd regions) clicks the page at raw coordinates — that layer always surfaces on the user's confirmation card. Call browser_see first for a fresh image and table.",
|
|
279
|
+
parameters: {
|
|
280
|
+
type: "object",
|
|
281
|
+
properties: {
|
|
282
|
+
x: { type: "number", description: "Horizontal CSS-pixel coordinate, from browser_see's screenshot or its @[x,y,w,h] table." },
|
|
283
|
+
y: { type: "number", description: "Vertical CSS-pixel coordinate, from browser_see's screenshot or its @[x,y,w,h] table." },
|
|
284
|
+
},
|
|
285
|
+
required: ["x", "y"],
|
|
286
|
+
additionalProperties: false,
|
|
287
|
+
},
|
|
288
|
+
isReadOnly: () => false,
|
|
289
|
+
isConcurrencySafe: () => false,
|
|
290
|
+
shouldDefer: () => false,
|
|
291
|
+
isOpenWorld: () => true,
|
|
292
|
+
async execute(input) {
|
|
293
|
+
// Fail-closed BEFORE any launch: coordinates are finite non-negative
|
|
294
|
+
// CSS pixels — anything else never touches the page.
|
|
295
|
+
const px = input["x"];
|
|
296
|
+
const py = input["y"];
|
|
297
|
+
if (typeof px !== "number" || !Number.isFinite(px) ||
|
|
298
|
+
typeof py !== "number" || !Number.isFinite(py) ||
|
|
299
|
+
px < 0 || py < 0) {
|
|
300
|
+
return err(`x and y must be finite non-negative numbers (CSS pixels, from browser_see's screenshot or @[x,y,w,h] table); got x=${JSON.stringify(px)}, y=${JSON.stringify(py)}.`);
|
|
301
|
+
}
|
|
302
|
+
if (runtime.state() === "closed")
|
|
303
|
+
return err(NOT_OPEN);
|
|
304
|
+
const snapshot = runtime.currentSnapshot();
|
|
305
|
+
if (snapshot === null) {
|
|
306
|
+
return err("No snapshot yet — call browser_see (or browser_snapshot) first: its image and @[x,y,w,h] table are this tool's coordinate source.");
|
|
307
|
+
}
|
|
308
|
+
// Layer 1 — hit: the full actionTarget channel (never bypassed).
|
|
309
|
+
const hit = hitRef(snapshot, px, py);
|
|
310
|
+
if (hit !== null) {
|
|
311
|
+
const t = await actionTarget(runtime, hit.ref);
|
|
312
|
+
if (t.kind === "err")
|
|
313
|
+
return t.result;
|
|
314
|
+
try {
|
|
315
|
+
await t.page.click(t.selector);
|
|
316
|
+
return ok(`(${px}, ${py}) hits ${hit.ref} (${t.desc}) — clicked by ref through the normal channel.${await navigationNote(runtime, t.page, t.snapshot.url, true)}`);
|
|
317
|
+
}
|
|
318
|
+
catch (e) {
|
|
319
|
+
return err(`Click on ${hit.ref} at (${px}, ${py}) failed (${msg(e)}). If the page changed, run browser_see for a fresh image and table.`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// Layer 2 — miss (canvas / an unref'd region): raw coordinate click.
|
|
323
|
+
try {
|
|
324
|
+
const page = await runtime.openPage();
|
|
325
|
+
const now = await page.url();
|
|
326
|
+
if (now !== snapshot.url) {
|
|
327
|
+
runtime.invalidateSnapshot();
|
|
328
|
+
return err(`The snapshot is stale — the page navigated to ${now} after it was taken, so these coordinates were measured on another page. Call browser_see again and retry with fresh coordinates.`);
|
|
329
|
+
}
|
|
330
|
+
const vp = await page.evaluate(VIEWPORT_PROBE);
|
|
331
|
+
const vw = vp !== null && typeof vp === "object" && typeof vp.w === "number" ? vp.w : Number.NaN;
|
|
332
|
+
const vh = vp !== null && typeof vp === "object" && typeof vp.h === "number" ? vp.h : Number.NaN;
|
|
333
|
+
if (!Number.isFinite(vw) || !Number.isFinite(vh) || px >= vw || py >= vh) {
|
|
334
|
+
return err(`(${px}, ${py}) is outside the current viewport${Number.isFinite(vw) && Number.isFinite(vh) ? ` (${vw}×${vh} CSS px)` : ""} — nothing there to click. Call browser_see and use coordinates inside the image.`);
|
|
335
|
+
}
|
|
336
|
+
await page.clickAt(px, py);
|
|
337
|
+
return ok(`Clicked at (${px}, ${py}) — the point hits no listed element's box (canvas/未命中层,已按坐标点击), so the page was clicked at raw coordinates.${await navigationNote(runtime, page, snapshot.url, true)}`);
|
|
338
|
+
}
|
|
339
|
+
catch (e) {
|
|
340
|
+
return err(`Coordinate click at (${px}, ${py}) failed (${msg(e)}). Call browser_see for a fresh image and table, then retry.`);
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
// ω99-140 — the ninth tool: type at CSS-pixel coordinates (the type pair's
|
|
345
|
+
// coordinate face, isomorphic to browser_click_xy's two layers). A hit
|
|
346
|
+
// routes through the FULL browser_type channel — the password gate VERBATIM
|
|
347
|
+
// (a coordinate is never a lane around the credential refusal); a miss
|
|
348
|
+
// (canvas / contenteditable / an unref'd field) clicks the raw coordinates
|
|
349
|
+
// to land focus, then typeText keystrokes into whatever received it — the
|
|
350
|
+
// field's type is UNKNOWN there and the result text says so honestly (the
|
|
351
|
+
// whole tool class always asks in the CLI's DEFAULT_ASK_TOOLS).
|
|
352
|
+
const browserTypeXy = {
|
|
353
|
+
name: "browser_type_xy",
|
|
354
|
+
description: "Type text at CSS-pixel page coordinates (x, y) — the same grid as browser_see's screenshot and its @[x,y,w,h] table. A point inside a listed field's box fills that field by ref (the normal type channel; password fields are refused). A point outside every box (canvas, contenteditable, unref'd fields) clicks there first to focus, then types keystrokes into whatever received focus — that layer never knows the field's type and says so. submit:true presses Enter after typing. Call browser_see first for a fresh image and table.",
|
|
355
|
+
parameters: {
|
|
356
|
+
type: "object",
|
|
357
|
+
properties: {
|
|
358
|
+
x: { type: "number", description: "Horizontal CSS-pixel coordinate, from browser_see's screenshot or its @[x,y,w,h] table." },
|
|
359
|
+
y: { type: "number", description: "Vertical CSS-pixel coordinate, from browser_see's screenshot or its @[x,y,w,h] table." },
|
|
360
|
+
text: { type: "string", description: "Text to type at the coordinates (a hit replaces the field's content)." },
|
|
361
|
+
submit: { type: "boolean", description: "Press Enter after typing (submits the form). Default false." },
|
|
362
|
+
},
|
|
363
|
+
required: ["x", "y", "text"],
|
|
364
|
+
additionalProperties: false,
|
|
365
|
+
},
|
|
366
|
+
isReadOnly: () => false,
|
|
367
|
+
isConcurrencySafe: () => false,
|
|
368
|
+
shouldDefer: () => false,
|
|
369
|
+
isOpenWorld: () => true,
|
|
370
|
+
async execute(input) {
|
|
371
|
+
// Fail-closed BEFORE any launch (139 discipline verbatim): coordinates
|
|
372
|
+
// are finite non-negative CSS pixels and the text a non-empty string —
|
|
373
|
+
// anything else never touches the page.
|
|
374
|
+
const px = input["x"];
|
|
375
|
+
const py = input["y"];
|
|
376
|
+
const text = input["text"];
|
|
377
|
+
const submit = input["submit"] ?? false;
|
|
378
|
+
if (typeof px !== "number" || !Number.isFinite(px) ||
|
|
379
|
+
typeof py !== "number" || !Number.isFinite(py) ||
|
|
380
|
+
px < 0 || py < 0) {
|
|
381
|
+
return err(`x and y must be finite non-negative numbers (CSS pixels, from browser_see's screenshot or @[x,y,w,h] table); got x=${JSON.stringify(px)}, y=${JSON.stringify(py)}.`);
|
|
382
|
+
}
|
|
383
|
+
if (typeof text !== "string" || text === "")
|
|
384
|
+
return err("text must be a non-empty string.");
|
|
385
|
+
if (typeof submit !== "boolean")
|
|
386
|
+
return err("submit must be a boolean.");
|
|
387
|
+
if (runtime.state() === "closed")
|
|
388
|
+
return err(NOT_OPEN);
|
|
389
|
+
const snapshot = runtime.currentSnapshot();
|
|
390
|
+
if (snapshot === null) {
|
|
391
|
+
return err("No snapshot yet — call browser_see (or browser_snapshot) first: its image and @[x,y,w,h] table are this tool's coordinate source.");
|
|
392
|
+
}
|
|
393
|
+
// Layer 1 — hit: the FULL browser_type channel, never bypassed — the
|
|
394
|
+
// password gate included (the coordinate must refuse exactly as hard).
|
|
395
|
+
const hit = hitRef(snapshot, px, py);
|
|
396
|
+
if (hit !== null) {
|
|
397
|
+
const t = await actionTarget(runtime, hit.ref);
|
|
398
|
+
if (t.kind === "err")
|
|
399
|
+
return t.result;
|
|
400
|
+
// ω99-140: the credential gate, VERBATIM from browser_type — a KNOWN
|
|
401
|
+
// password field never receives model-typed text, whichever channel
|
|
402
|
+
// (ref or coordinate) addressed it; zero fill, zero typeText.
|
|
403
|
+
if (t.entry.type === "password")
|
|
404
|
+
return err(PASSWORD_REFUSED(hit.ref));
|
|
405
|
+
try {
|
|
406
|
+
await t.page.fill(t.selector, text);
|
|
407
|
+
if (submit)
|
|
408
|
+
await t.page.press("Enter");
|
|
409
|
+
return ok(`(${px}, ${py}) hits ${hit.ref} (${t.desc}) — typed by ref through the normal channel${submit ? ", Enter pressed" : ""}.${await navigationNote(runtime, t.page, t.snapshot.url, submit)}`);
|
|
410
|
+
}
|
|
411
|
+
catch (e) {
|
|
412
|
+
return err(`Typing into ${hit.ref} at (${px}, ${py}) failed (${msg(e)}). If the page changed, run browser_see for a fresh image and table.`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// Layer 2 — miss (canvas / an unref'd field): click the raw coordinates
|
|
416
|
+
// to land focus, then keystroke the text into whatever received it.
|
|
417
|
+
try {
|
|
418
|
+
const page = await runtime.openPage();
|
|
419
|
+
const now = await page.url();
|
|
420
|
+
if (now !== snapshot.url) {
|
|
421
|
+
runtime.invalidateSnapshot();
|
|
422
|
+
return err(`The snapshot is stale — the page navigated to ${now} after it was taken, so these coordinates were measured on another page. Call browser_see again and retry with fresh coordinates.`);
|
|
423
|
+
}
|
|
424
|
+
const vp = await page.evaluate(VIEWPORT_PROBE);
|
|
425
|
+
const vw = vp !== null && typeof vp === "object" && typeof vp.w === "number" ? vp.w : Number.NaN;
|
|
426
|
+
const vh = vp !== null && typeof vp === "object" && typeof vp.h === "number" ? vp.h : Number.NaN;
|
|
427
|
+
if (!Number.isFinite(vw) || !Number.isFinite(vh) || px >= vw || py >= vh) {
|
|
428
|
+
return err(`(${px}, ${py}) is outside the current viewport${Number.isFinite(vw) && Number.isFinite(vh) ? ` (${vw}×${vh} CSS px)` : ""} — nothing there to type into. Call browser_see and use coordinates inside the image.`);
|
|
429
|
+
}
|
|
430
|
+
// ω99-140: click FIRST (focus), then keystroke — one combined channel,
|
|
431
|
+
// so a failure of either surfaces as one honest coordinate error.
|
|
432
|
+
await page.clickAt(px, py);
|
|
433
|
+
await page.typeText(text);
|
|
434
|
+
if (submit)
|
|
435
|
+
await page.press("Enter");
|
|
436
|
+
return ok(`Typed at (${px}, ${py}) — 未命中层,已按坐标点击后打字(字段类型未知):the point hits no listed element's box, so focus landed on whatever the click hit and the text was keystroked into it${submit ? ", then Enter" : ""}.${await navigationNote(runtime, page, snapshot.url, submit)}`);
|
|
437
|
+
}
|
|
438
|
+
catch (e) {
|
|
439
|
+
return err(`Coordinate typing at (${px}, ${py}) failed (${msg(e)}). Call browser_see for a fresh image and table, then retry.`);
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
// ω99-141 — the tenth tool: scroll the container UNDER CSS-pixel
|
|
444
|
+
// coordinates. browser_scroll drives window.scrollBy — page-level only,
|
|
445
|
+
// blind to the embedded panes of online design tools (a layers sidebar,
|
|
446
|
+
// a canvas viewport); browser_scroll_xy instead dispatches a WHEEL at the
|
|
447
|
+
// point, and the browser routes it to whatever scrollable container sits
|
|
448
|
+
// there. NO hit layer — scrolling needs no target identification (wherever
|
|
449
|
+
// the point sits is what scrolls); the 139/140 preamble (stale guard,
|
|
450
|
+
// viewport probe) is the whole story. Read-only like browser_scroll, so
|
|
451
|
+
// the permission tiers auto-allow it (ZERO ask surface — this arc does not
|
|
452
|
+
// touch permission.ts).
|
|
453
|
+
const browserScrollXy = {
|
|
454
|
+
name: "browser_scroll_xy",
|
|
455
|
+
description: "Scroll the container under CSS-pixel coordinates (x, y) up or down by about one screen — the same coordinate grid as browser_see's screenshot and its @[x,y,w,h] table. Unlike browser_scroll (page-level), the wheel reaches whatever scrollable pane sits under the point: an online design tool's layers sidebar, a canvas viewport, any embedded panel. Read-only; refs stay valid while the page does not navigate. Call browser_see first for a fresh image and table.",
|
|
456
|
+
parameters: {
|
|
457
|
+
type: "object",
|
|
458
|
+
properties: {
|
|
459
|
+
x: { type: "number", description: "Horizontal CSS-pixel coordinate, from browser_see's screenshot or its @[x,y,w,h] table." },
|
|
460
|
+
y: { type: "number", description: "Vertical CSS-pixel coordinate, from browser_see's screenshot or its @[x,y,w,h] table." },
|
|
461
|
+
direction: { type: "string", description: '"down" or "up" — one wheel of ~90% of the viewport height (browser_scroll\'s own convention), applied to the container under (x, y).' },
|
|
462
|
+
},
|
|
463
|
+
required: ["x", "y", "direction"],
|
|
464
|
+
additionalProperties: false,
|
|
465
|
+
},
|
|
466
|
+
isReadOnly: () => true,
|
|
467
|
+
isConcurrencySafe: () => false,
|
|
468
|
+
shouldDefer: () => false,
|
|
469
|
+
isOpenWorld: () => true,
|
|
470
|
+
async execute(input) {
|
|
471
|
+
// Fail-closed BEFORE any launch (139 discipline verbatim): coordinates
|
|
472
|
+
// are finite non-negative CSS pixels and direction is up|down —
|
|
473
|
+
// anything else never touches the page.
|
|
474
|
+
const px = input["x"];
|
|
475
|
+
const py = input["y"];
|
|
476
|
+
const direction = input["direction"];
|
|
477
|
+
if (typeof px !== "number" || !Number.isFinite(px) ||
|
|
478
|
+
typeof py !== "number" || !Number.isFinite(py) ||
|
|
479
|
+
px < 0 || py < 0) {
|
|
480
|
+
return err(`x and y must be finite non-negative numbers (CSS pixels, from browser_see's screenshot or @[x,y,w,h] table); got x=${JSON.stringify(px)}, y=${JSON.stringify(py)}.`);
|
|
481
|
+
}
|
|
482
|
+
if (direction !== "up" && direction !== "down")
|
|
483
|
+
return err('direction must be "up" or "down".');
|
|
484
|
+
if (runtime.state() === "closed")
|
|
485
|
+
return err(NOT_OPEN);
|
|
486
|
+
const snapshot = runtime.currentSnapshot();
|
|
487
|
+
if (snapshot === null) {
|
|
488
|
+
return err("No snapshot yet — call browser_see (or browser_snapshot) first: its image and @[x,y,w,h] table are this tool's coordinate source.");
|
|
489
|
+
}
|
|
490
|
+
// No hit layer by design: the wheel is addressed to a POINT, not an
|
|
491
|
+
// element — whatever scrollable container sits under it is what
|
|
492
|
+
// scrolls (a plain page point scrolls the page, a pane point scrolls
|
|
493
|
+
// the pane). The stale guard + viewport probe are the full preamble.
|
|
494
|
+
try {
|
|
495
|
+
const page = await runtime.openPage();
|
|
496
|
+
const now = await page.url();
|
|
497
|
+
if (now !== snapshot.url) {
|
|
498
|
+
runtime.invalidateSnapshot();
|
|
499
|
+
return err(`The snapshot is stale — the page navigated to ${now} after it was taken, so these coordinates were measured on another page. Call browser_see again and retry with fresh coordinates.`);
|
|
500
|
+
}
|
|
501
|
+
const vp = await page.evaluate(VIEWPORT_PROBE);
|
|
502
|
+
const vw = vp !== null && typeof vp === "object" && typeof vp.w === "number" ? vp.w : Number.NaN;
|
|
503
|
+
const vh = vp !== null && typeof vp === "object" && typeof vp.h === "number" ? vp.h : Number.NaN;
|
|
504
|
+
if (!Number.isFinite(vw) || !Number.isFinite(vh) || px >= vw || py >= vh) {
|
|
505
|
+
return err(`(${px}, ${py}) is outside the current viewport${Number.isFinite(vw) && Number.isFinite(vh) ? ` (${vw}×${vh} CSS px)` : ""} — no container sits under it to scroll. Call browser_see and use coordinates inside the image.`);
|
|
506
|
+
}
|
|
507
|
+
// One wheel = ~90% of the viewport height — browser_scroll's own
|
|
508
|
+
// convention; down is a positive delta, up negative.
|
|
509
|
+
const dy = Math.round(vh * 0.9) * (direction === "down" ? 1 : -1);
|
|
510
|
+
await page.wheelAt(px, py, 0, dy);
|
|
511
|
+
return ok(`Scrolled ${direction} at (${px}, ${py}) — the wheel landed on whatever container sits under the coordinates (坐标之下的滚动容器,canvas 内嵌面板在内), not necessarily the page body. Refs stay valid while the page does not navigate; run browser_see to see newly revealed elements.`);
|
|
512
|
+
}
|
|
513
|
+
catch (e) {
|
|
514
|
+
return err(`Coordinate scroll at (${px}, ${py}) failed (${msg(e)}). Call browser_see for a fresh image and table, then retry.`);
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
// ω99-142 — the eleventh tool: the page-download manifest (M-CU2's missing
|
|
519
|
+
// last mile — "改标题 + 导出 PNG" ends with SEEING the exported file). The
|
|
520
|
+
// port intercepts context-level download events and saves them under
|
|
521
|
+
// sanitized, timestamp-prefixed names; this tool lists what COMPLETED —
|
|
522
|
+
// a save still in flight never appears (honest numbers, no phantom rows;
|
|
523
|
+
// the model polls after an export click). ZERO parameters by design: the
|
|
524
|
+
// smallest injection surface there is. Closed-world read of local records
|
|
525
|
+
// (no page touch, no network) → isConcurrencySafe, and the
|
|
526
|
+
// isReadOnly:true + isOpenWorld:false pair is the sidecar's no-audit
|
|
527
|
+
// shape; the permission tiers auto-allow it (permission.ts untouched).
|
|
528
|
+
const browserDownloads = {
|
|
529
|
+
name: "browser_downloads",
|
|
530
|
+
description: "List the files the page has downloaded (an export button's output — a PNG, a PDF, a spreadsheet): each with its saved filename, size in bytes, and absolute path on disk. A download triggered moments ago may still be saving — if the file you expect is missing, call this again shortly.",
|
|
531
|
+
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
532
|
+
isReadOnly: () => true,
|
|
533
|
+
isConcurrencySafe: () => true,
|
|
534
|
+
shouldDefer: () => false,
|
|
535
|
+
isOpenWorld: () => false,
|
|
536
|
+
async execute() {
|
|
537
|
+
if (runtime.state() === "closed")
|
|
538
|
+
return err(NOT_OPEN);
|
|
539
|
+
try {
|
|
540
|
+
const page = await runtime.openPage();
|
|
541
|
+
const list = await page.downloads?.();
|
|
542
|
+
if (list === undefined || list.length === 0) {
|
|
543
|
+
return ok("No downloads yet — a file exported by the page (an export button click) lands here.");
|
|
544
|
+
}
|
|
545
|
+
return ok(`Downloads saved by the page (oldest first):\n${list.map((r) => `${r.filename} (${r.bytes} B) → ${r.path}`).join("\n")}`);
|
|
546
|
+
}
|
|
547
|
+
catch (e) {
|
|
548
|
+
return err(msg(e));
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
return [
|
|
553
|
+
browserOpen,
|
|
554
|
+
browserSnapshot,
|
|
555
|
+
browserSee,
|
|
556
|
+
{
|
|
557
|
+
name: "browser_click",
|
|
558
|
+
description: "Click a clickable element by its snapshot ref (e1, e2, …). Buttons that submit forms, post, order, or confirm are submission-class: they surface on the user's confirmation card by default (the user can approve once or always). Refs come from the latest browser_snapshot and are voided by any navigation.",
|
|
559
|
+
parameters: { type: "object", properties: { ref: { type: "string", description: 'Element ref from the latest browser_snapshot, e.g. "e1".' } }, required: ["ref"], additionalProperties: false },
|
|
560
|
+
isReadOnly: () => false,
|
|
561
|
+
isConcurrencySafe: () => false,
|
|
562
|
+
shouldDefer: () => false,
|
|
563
|
+
isOpenWorld: () => true,
|
|
564
|
+
async execute(input) {
|
|
565
|
+
const t = await actionTarget(runtime, input["ref"]);
|
|
566
|
+
if (t.kind === "err")
|
|
567
|
+
return t.result;
|
|
568
|
+
try {
|
|
569
|
+
await t.page.click(t.selector);
|
|
570
|
+
return ok(`Clicked ${String(input["ref"])} (${t.desc}).${await navigationNote(runtime, t.page, t.snapshot.url, true)}`);
|
|
571
|
+
}
|
|
572
|
+
catch (e) {
|
|
573
|
+
// ω99-144: browser_see 同刷 ref 表(138 语义),降级文案并列路由。
|
|
574
|
+
return err(`Click on ${String(input["ref"])} failed (${msg(e)}). If the page changed, run browser_see or browser_snapshot for fresh refs.`);
|
|
575
|
+
}
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
browserClickXy,
|
|
579
|
+
{
|
|
580
|
+
name: "browser_type",
|
|
581
|
+
description: "Type text into a form field addressed by its snapshot ref (fills the field, replacing its content). With submit:true an Enter follows the text — the same confirmation posture as clicking a submit button. Refs come from the latest browser_snapshot and are voided by any navigation.",
|
|
582
|
+
parameters: {
|
|
583
|
+
type: "object",
|
|
584
|
+
properties: {
|
|
585
|
+
ref: { type: "string", description: 'Field ref from the latest browser_snapshot, e.g. "e2".' },
|
|
586
|
+
text: { type: "string", description: "Text to type into the field (replaces current content)." },
|
|
587
|
+
submit: { type: "boolean", description: "Press Enter after typing (submits the form). Default false." },
|
|
588
|
+
},
|
|
589
|
+
required: ["ref", "text"],
|
|
590
|
+
additionalProperties: false,
|
|
591
|
+
},
|
|
592
|
+
isReadOnly: () => false,
|
|
593
|
+
isConcurrencySafe: () => false,
|
|
594
|
+
shouldDefer: () => false,
|
|
595
|
+
isOpenWorld: () => true,
|
|
596
|
+
async execute(input) {
|
|
597
|
+
const text = input["text"];
|
|
598
|
+
if (typeof text !== "string" || text === "")
|
|
599
|
+
return err("text must be a non-empty string.");
|
|
600
|
+
const submit = input["submit"] ?? false;
|
|
601
|
+
if (typeof submit !== "boolean")
|
|
602
|
+
return err("submit must be a boolean.");
|
|
603
|
+
const t = await actionTarget(runtime, input["ref"]);
|
|
604
|
+
if (t.kind === "err")
|
|
605
|
+
return t.result;
|
|
606
|
+
// ω99-114 fail-closed credential gate: a KNOWN password field never
|
|
607
|
+
// receives model-typed text (an unknown type is not a password —
|
|
608
|
+
// the gate refuses only on certain knowledge, never guesses).
|
|
609
|
+
if (t.entry.type === "password")
|
|
610
|
+
return err(PASSWORD_REFUSED(String(input["ref"])));
|
|
611
|
+
try {
|
|
612
|
+
await t.page.fill(t.selector, text);
|
|
613
|
+
if (submit)
|
|
614
|
+
await t.page.press("Enter");
|
|
615
|
+
return ok(`Typed into ${String(input["ref"])} (${t.desc})${submit ? " and pressed Enter" : ""}.${await navigationNote(runtime, t.page, t.snapshot.url, submit)}`);
|
|
616
|
+
}
|
|
617
|
+
catch (e) {
|
|
618
|
+
// ω99-144: browser_see 同刷 ref 表(138 语义),降级文案并列路由。
|
|
619
|
+
return err(`Typing into ${String(input["ref"])} failed (${msg(e)}). If the page changed, run browser_see or browser_snapshot for fresh refs.`);
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
// ω99-140: the type pair — browser_type_xy sits right after browser_type.
|
|
624
|
+
browserTypeXy,
|
|
625
|
+
{
|
|
626
|
+
name: "browser_scroll",
|
|
627
|
+
description: "Scroll the current page up or down by about one screen. Read-only; refs stay valid while the page does not navigate — take a fresh browser_snapshot to see newly revealed elements.",
|
|
628
|
+
parameters: {
|
|
629
|
+
type: "object",
|
|
630
|
+
properties: { direction: { type: "string", description: '"down" (default direction of reading) or "up".' } },
|
|
631
|
+
required: ["direction"],
|
|
632
|
+
additionalProperties: false,
|
|
633
|
+
},
|
|
634
|
+
isReadOnly: () => true,
|
|
635
|
+
isConcurrencySafe: () => false,
|
|
636
|
+
shouldDefer: () => false,
|
|
637
|
+
isOpenWorld: () => true,
|
|
638
|
+
async execute(input) {
|
|
639
|
+
if (runtime.state() === "closed")
|
|
640
|
+
return err(NOT_OPEN);
|
|
641
|
+
const direction = input["direction"];
|
|
642
|
+
if (direction !== "up" && direction !== "down")
|
|
643
|
+
return err('direction must be "up" or "down".');
|
|
644
|
+
try {
|
|
645
|
+
const page = await runtime.openPage();
|
|
646
|
+
const sign = direction === "down" ? "" : "-";
|
|
647
|
+
await page.evaluate(`window.scrollBy(0, ${sign}window.innerHeight * 0.9)`);
|
|
648
|
+
// ω99-144: browser_see 同刷 ref 表(138 语义),降级文案并列路由。
|
|
649
|
+
return ok(`Scrolled ${direction} ~one screen. Refs stay valid while the page stays put; browser_see (or browser_snapshot) reveals newly visible elements.`);
|
|
650
|
+
}
|
|
651
|
+
catch (e) {
|
|
652
|
+
return err(msg(e));
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
},
|
|
656
|
+
// ω99-141: the scroll pair — browser_scroll_xy sits right after
|
|
657
|
+
// browser_scroll (page-level scroll keeps its lane; the coordinate face
|
|
658
|
+
// reaches the containers under a point).
|
|
659
|
+
browserScrollXy,
|
|
660
|
+
// ω99-142: the download manifest closes the coordinate trio's export
|
|
661
|
+
// flows — right after the trio, right before browser_close.
|
|
662
|
+
browserDownloads,
|
|
663
|
+
{
|
|
664
|
+
name: "browser_close",
|
|
665
|
+
description: "Close the browser window (idempotent). The browser also closes itself after 10 idle minutes, so orphaned windows do not linger.",
|
|
666
|
+
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
667
|
+
isReadOnly: () => true,
|
|
668
|
+
isConcurrencySafe: () => false,
|
|
669
|
+
shouldDefer: () => false,
|
|
670
|
+
isOpenWorld: () => true,
|
|
671
|
+
async execute() {
|
|
672
|
+
await runtime.close();
|
|
673
|
+
return ok("Browser closed.");
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
];
|
|
677
|
+
}
|
|
678
|
+
//# sourceMappingURL=tools.js.map
|