dsh-browser-playwright-codex 0.5.2 → 0.5.3

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,137 @@
1
+ import { CHALLENGE_NOTE, detectChallenge } from "./challenge.js";
2
+ import { renderSnapshot, renderSnapshotDiff } from "./snapshot-render.js";
3
+ /**
4
+ * Render one diagnostics payload as one line per entry. Diagnostics are read when
5
+ * something already looks wrong, so the compact form matters more than the rich
6
+ * one: the model needs the level, the message and the source, not a JSON tree.
7
+ */
8
+ export function renderDiagnostics(kind, value) {
9
+ if (value === null || typeof value !== 'object')
10
+ return String(value);
11
+ const label = kind === 'console'
12
+ ? 'console (' + String(value.returned) + '/' + String(value.total) + ' shown, level=' + String(value.level) + ')'
13
+ : 'network (' + String(value.returned) + '/' + String(value.total) + ' shown)';
14
+ const lines = [label + (value.url === null ? '' : ' @ ' + String(value.url))];
15
+ if ((value.dropped ?? 0) > 0)
16
+ lines.push('[' + String(value.dropped) + ' older entries evicted]');
17
+ // Scope disclosure. Without this line an empty result looks like "the page is
18
+ // quiet" even when another tab of the shared window is throwing: that is how a
19
+ // model ends up diagnosing a page it is not even looking at.
20
+ if ((value.otherPages ?? 0) > 0)
21
+ lines.push('[' +
22
+ String(value.otherPages) +
23
+ ' matching ' +
24
+ (kind === 'console' ? 'entries' : 'requests') +
25
+ ' from other tabs of the same window not shown — this reports only the tab this session is driving]');
26
+ const entries = value.entries ?? [];
27
+ for (const entry of entries) {
28
+ if (kind === 'console') {
29
+ lines.push('#' +
30
+ String(entry.n) +
31
+ ' [' +
32
+ String(entry.level) +
33
+ '] ' +
34
+ String(entry.text) +
35
+ (entry.source ? ' <- ' + String(entry.source) : ''));
36
+ }
37
+ else {
38
+ const outcome = entry.failure !== null && entry.failure !== undefined
39
+ ? 'FAILED ' + String(entry.failure)
40
+ : String(entry.status === null ? 'pending' : entry.status);
41
+ lines.push('#' +
42
+ String(entry.n) +
43
+ ' ' +
44
+ String(entry.method) +
45
+ ' ' +
46
+ outcome +
47
+ ' ' +
48
+ String(entry.kind) +
49
+ (entry.ms === null ? '' : ' ' + String(entry.ms) + 'ms') +
50
+ ' ' +
51
+ String(entry.url));
52
+ }
53
+ }
54
+ if (entries.length === 0)
55
+ lines.push('(nothing captured yet)');
56
+ return lines.join('\n');
57
+ }
58
+ /** Project a provider snapshot into its canonical tool value. */
59
+ export function snapshotValue(snapshot) {
60
+ const challenge = detectChallenge(snapshot);
61
+ const tree = renderSnapshot(snapshot);
62
+ const notes = [];
63
+ const dialog = snapshot.dialogNote;
64
+ if (dialog !== undefined && dialog !== '')
65
+ notes.push(dialog);
66
+ const landing = snapshot.landingNote;
67
+ if (landing !== undefined && landing !== '')
68
+ notes.push(landing);
69
+ // Engine fallback disclosure. The user explicitly chose a page-reading mode in
70
+ // Settings; when the provider has to serve the other engine's tree, saying so
71
+ // is the difference between a checked assumption and a silent one.
72
+ const engine = snapshot.engineNote;
73
+ if (engine !== undefined && engine !== '')
74
+ notes.push('[page reader] ' + engine);
75
+ const head = notes.length > 0 ? notes.join('\n\n') + '\n\n' : '';
76
+ // Incremental diff rides along when the provider computed one (snapshot.diff).
77
+ const diffBlock = snapshot.diff !== undefined ? '\n\n' + renderSnapshotDiff(snapshot.diff) : '';
78
+ return {
79
+ url: snapshot.url,
80
+ title: snapshot.title,
81
+ refs: snapshot.totalRefs,
82
+ truncated: snapshot.truncated,
83
+ tree: head +
84
+ (challenge === null ? tree : challenge.engine + ': ' + challenge.hint + '.\n' + CHALLENGE_NOTE + '\n\n' + tree) +
85
+ diffBlock,
86
+ };
87
+ }
88
+ /** Render a snapshot value as one text block. */
89
+ export function renderSnapshotValue(_args, value) {
90
+ return [{ type: 'text', text: value.tree }];
91
+ }
92
+ export function renderTabsValue(_args, value) {
93
+ if (value.tabs.length === 0)
94
+ return [{ type: 'text', text: 'No open tabs.' }];
95
+ const lines = value.tabs.map((tab) => String(tab.index) + '.' + (tab.active ? ' [active]' : '') + ' ' + (tab.title || tab.url));
96
+ return [{ type: 'text', text: lines.join('\n') }];
97
+ }
98
+ const SCREENSHOT_EXTENSIONS = {
99
+ 'image/png': '.png',
100
+ 'image/jpeg': '.jpg',
101
+ 'image/webp': '.webp',
102
+ 'image/gif': '.gif',
103
+ };
104
+ /** Render a screenshot as its summary plus the image block itself. */
105
+ export function renderScreenshotValue(_args, value) {
106
+ // The attachment store normalizes a capture before storing it: a metadata-carrying PNG
107
+ // is re-encoded to JPEG (no alpha) or WebP (alpha). The durable reference must therefore
108
+ // carry the media type the store returned -- hardcoding 'image/png' here makes every
109
+ // later request containing this message fail with ATTACHMENT_CORRUPT before the model
110
+ // call is even attempted (surfacing as a bogus transport error).
111
+ const mediaType = typeof value.mediaType === 'string' && value.mediaType.length > 0 ? value.mediaType : 'image/png';
112
+ const summary = 'Screenshot of ' +
113
+ value.url +
114
+ ': ' +
115
+ value.width +
116
+ 'x' +
117
+ value.height +
118
+ ' px, ' +
119
+ value.bytes +
120
+ ' bytes (' +
121
+ mediaType.slice('image/'.length).toUpperCase() +
122
+ ' attachment).';
123
+ // The store brands its id and media type; the canonical value carries plain
124
+ // strings, so the assertion happens once, here, at the boundary.
125
+ const attachment = {
126
+ attachmentId: value.attachmentId,
127
+ mediaType,
128
+ bytes: value.bytes,
129
+ width: value.width,
130
+ height: value.height,
131
+ name: 'browser-screenshot' + (SCREENSHOT_EXTENSIONS[mediaType] ?? '.png'),
132
+ };
133
+ return [
134
+ { type: 'text', text: summary },
135
+ { type: 'image', attachment },
136
+ ];
137
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * 工具参数 schema:快照、标签、截图三个工具的参数外形,与工具注册处共用同一份定义。
3
+ * @module dsh-browser-playwright-codex/tool-schemas
4
+ */
5
+ export declare const SNAPSHOT_SCHEMA: {
6
+ readonly type: "object";
7
+ readonly properties: {
8
+ readonly url: {
9
+ readonly type: "string";
10
+ };
11
+ readonly title: {
12
+ readonly type: "string";
13
+ };
14
+ readonly refs: {
15
+ readonly type: "integer";
16
+ };
17
+ readonly truncated: {
18
+ readonly type: "boolean";
19
+ };
20
+ readonly tree: {
21
+ readonly type: "string";
22
+ };
23
+ };
24
+ readonly additionalProperties: false;
25
+ };
26
+ export declare const TABS_SCHEMA: {
27
+ readonly type: "object";
28
+ readonly properties: {
29
+ readonly tabs: {
30
+ readonly type: "array";
31
+ readonly items: {
32
+ readonly type: "object";
33
+ readonly properties: {
34
+ readonly index: {
35
+ readonly type: "integer";
36
+ };
37
+ readonly url: {
38
+ readonly type: "string";
39
+ };
40
+ readonly title: {
41
+ readonly type: "string";
42
+ };
43
+ readonly active: {
44
+ readonly type: "boolean";
45
+ };
46
+ };
47
+ readonly additionalProperties: false;
48
+ };
49
+ };
50
+ };
51
+ readonly additionalProperties: false;
52
+ };
53
+ export declare const SCREENSHOT_SCHEMA: {
54
+ readonly type: "object";
55
+ readonly properties: {
56
+ readonly attachmentId: {
57
+ readonly type: "string";
58
+ };
59
+ readonly mediaType: {
60
+ readonly type: "string";
61
+ };
62
+ readonly bytes: {
63
+ readonly type: "integer";
64
+ };
65
+ readonly width: {
66
+ readonly type: "integer";
67
+ };
68
+ readonly height: {
69
+ readonly type: "integer";
70
+ };
71
+ readonly url: {
72
+ readonly type: "string";
73
+ };
74
+ };
75
+ readonly additionalProperties: false;
76
+ };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 工具参数 schema:快照、标签、截图三个工具的参数外形,与工具注册处共用同一份定义。
3
+ * @module dsh-browser-playwright-codex/tool-schemas
4
+ */
5
+ export const SNAPSHOT_SCHEMA = {
6
+ type: 'object',
7
+ properties: {
8
+ url: { type: 'string' },
9
+ title: { type: 'string' },
10
+ refs: { type: 'integer' },
11
+ truncated: { type: 'boolean' },
12
+ tree: { type: 'string' },
13
+ },
14
+ additionalProperties: false,
15
+ };
16
+ export const TABS_SCHEMA = {
17
+ type: 'object',
18
+ properties: {
19
+ tabs: {
20
+ type: 'array',
21
+ items: {
22
+ type: 'object',
23
+ properties: {
24
+ index: { type: 'integer' },
25
+ url: { type: 'string' },
26
+ title: { type: 'string' },
27
+ active: { type: 'boolean' },
28
+ },
29
+ additionalProperties: false,
30
+ },
31
+ },
32
+ },
33
+ additionalProperties: false,
34
+ };
35
+ export const SCREENSHOT_SCHEMA = {
36
+ type: 'object',
37
+ properties: {
38
+ attachmentId: { type: 'string' },
39
+ mediaType: { type: 'string' },
40
+ bytes: { type: 'integer' },
41
+ width: { type: 'integer' },
42
+ height: { type: 'integer' },
43
+ url: { type: 'string' },
44
+ },
45
+ additionalProperties: false,
46
+ };
@@ -0,0 +1,82 @@
1
+ /**
2
+ * 工具面类型:各工具的返回值形状(快照/标签/截图/诊断)与辅助服务接口。
3
+ * @module dsh-browser-playwright-codex/tool-types
4
+ */
5
+ import type { StreamChunk } from '@deepseek-ai/dsh-llm';
6
+ /**
7
+ * JSON value: the shape every tool value must be serializable to, so the
8
+ * framework can put it on the wire. Declared locally because the installed
9
+ * dsh-session build does not re-export one.
10
+ */
11
+ export type JsonValue = string | number | boolean | null | JsonValue[] | {
12
+ [key: string]: JsonValue;
13
+ };
14
+ /** One actionable element row as the renderer sees it. */
15
+ export interface SnapshotValue {
16
+ url: string;
17
+ title: string;
18
+ refs: number;
19
+ truncated: boolean;
20
+ tree: string;
21
+ }
22
+ /** One open tab row as the renderer sees it. */
23
+ export interface TabRow {
24
+ index: number;
25
+ url: string;
26
+ title: string;
27
+ active: boolean;
28
+ }
29
+ /** Canonical value of browser_tabs. */
30
+ export interface TabsValue {
31
+ tabs: TabRow[];
32
+ }
33
+ /** Canonical value of browser_screenshot. */
34
+ export interface ScreenshotValue {
35
+ attachmentId: string;
36
+ mediaType: string;
37
+ bytes: number;
38
+ width: number;
39
+ height: number;
40
+ url: string;
41
+ }
42
+ /** Minimal node shape the challenge detector walks. */
43
+ export interface ChallengeNode {
44
+ readonly name?: string;
45
+ readonly children?: readonly ChallengeNode[];
46
+ }
47
+ /** The slice of a snapshot the challenge detector reads. */
48
+ export interface ChallengeSnapshot {
49
+ readonly title?: string;
50
+ readonly url?: string;
51
+ readonly nodes?: readonly ChallengeNode[];
52
+ }
53
+ /** What the challenge detector reports when a page is an anti-bot wall. */
54
+ export interface ChallengeMatch {
55
+ engine: string;
56
+ hint: string;
57
+ }
58
+ /** The attachments-service face consumed at execution time. */
59
+ export interface AttachmentServiceLike {
60
+ saveImage(input: {
61
+ data: Uint8Array;
62
+ mediaType: string;
63
+ name: string;
64
+ }): Promise<SavedImageRef>;
65
+ }
66
+ /** Durable reference returned by the attachment store. */
67
+ export interface SavedImageRef {
68
+ attachmentId: string;
69
+ mediaType: string;
70
+ bytes: number;
71
+ width: number;
72
+ height: number;
73
+ }
74
+ /** The llm-service face consumed at execution time. */
75
+ export interface LlmServiceLike {
76
+ stream(options: unknown): AsyncIterable<StreamChunk>;
77
+ }
78
+ /** Optional service faces consumed at execution time. */
79
+ export interface OptionalServices {
80
+ attachments?: AttachmentServiceLike;
81
+ llm?: LlmServiceLike;
82
+ }
@@ -0,0 +1 @@
1
+ export {};
package/lib/tool.d.ts CHANGED
@@ -10,6 +10,8 @@
10
10
  */
11
11
  import type { Context } from '@deepseek-ai/cordis';
12
12
  import z from '@deepseek-ai/schemastery';
13
+ import { detectChallenge } from './challenge.ts';
14
+ export { detectChallenge };
13
15
  /** Cordis plugin name used by loader diagnostics. */
14
16
  export declare const name = "browser-tool";
15
17
  /** The browser runtime and tool registry this plugin consumes. */
@@ -41,37 +43,11 @@ export interface ToolConfig {
41
43
  maxOutputTokens: number;
42
44
  };
43
45
  }
44
- /** Minimal node shape the challenge detector walks. */
45
- interface ChallengeNode {
46
- readonly name?: string;
47
- readonly children?: readonly ChallengeNode[];
48
- }
49
- /** The slice of a snapshot the challenge detector reads. */
50
- interface ChallengeSnapshot {
51
- readonly title?: string;
52
- readonly url?: string;
53
- readonly nodes?: readonly ChallengeNode[];
54
- }
55
- /** What the challenge detector reports when a page is an anti-bot wall. */
56
- interface ChallengeMatch {
57
- engine: string;
58
- hint: string;
59
- }
60
46
  /** Schemastery validation for {@link ToolConfig}. */
61
47
  export declare const Config: z<ToolConfig>;
62
- /**
63
- * Detect anti-bot / human-verification challenge pages (Cloudflare
64
- * interstitial, Turnstile, reCAPTCHA-style) from a snapshot's title, url and
65
- * visible text. We do NOT try to solve them: the correct move for an agent is
66
- * to hand the challenge to the human who can see the open window. Returns the
67
- * engine name when matched, else null. Matching is conservative to avoid
68
- * false positives on ordinary pages.
69
- */
70
- export declare function detectChallenge(snapshot: ChallengeSnapshot): ChallengeMatch | null;
71
48
  /**
72
49
  * Register the browser tool family on ctx.tools.
73
50
  * @param ctx - plugin context carrying browser, tools, and the optional services.
74
51
  * @param config - tool naming and safety configuration.
75
52
  */
76
53
  export declare function apply(ctx: Context, config: ToolConfig): void;
77
- export {};
package/lib/tool.js CHANGED
@@ -3,9 +3,13 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
3
3
  import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm';
4
4
  import { BrowserError } from "./errors.js";
5
5
  import { registeredSuffixes } from "./contract.js";
6
- import { renderSnapshot, renderSnapshotDiff } from "./snapshot-render.js";
7
6
  import { getEnabled, publishGates } from "./runtime-state.js";
8
7
  import { isCdpMethodAllowed, cdpDenialMessage } from "./cdp-policy.js";
8
+ import { SCREENSHOT_SCHEMA, SNAPSHOT_SCHEMA, TABS_SCHEMA } from "./tool-schemas.js";
9
+ import { parseJsonText, extractionPrompt } from "./extract.js";
10
+ import { detectChallenge } from "./challenge.js";
11
+ import { renderDiagnostics, renderScreenshotValue, renderSnapshotValue, renderTabsValue, snapshotValue, } from "./tool-render.js";
12
+ export { detectChallenge };
9
13
  /** Cordis plugin name used by loader diagnostics. */
10
14
  export const name = 'browser-tool';
11
15
  /** The browser runtime and tool registry this plugin consumes. */
@@ -25,257 +29,6 @@ export const Config = z.object({
25
29
  maxOutputTokens: z.number().default(2000),
26
30
  }),
27
31
  });
28
- /**
29
- * Render one diagnostics payload as one line per entry. Diagnostics are read when
30
- * something already looks wrong, so the compact form matters more than the rich
31
- * one: the model needs the level, the message and the source, not a JSON tree.
32
- */
33
- function renderDiagnostics(kind, value) {
34
- if (value === null || typeof value !== 'object')
35
- return String(value);
36
- const label = kind === 'console'
37
- ? 'console (' + String(value.returned) + '/' + String(value.total) + ' shown, level=' + String(value.level) + ')'
38
- : 'network (' + String(value.returned) + '/' + String(value.total) + ' shown)';
39
- const lines = [label + (value.url === null ? '' : ' @ ' + String(value.url))];
40
- if ((value.dropped ?? 0) > 0)
41
- lines.push('[' + String(value.dropped) + ' older entries evicted]');
42
- // Scope disclosure. Without this line an empty result looks like "the page is
43
- // quiet" even when another tab of the shared window is throwing: that is how a
44
- // model ends up diagnosing a page it is not even looking at.
45
- if ((value.otherPages ?? 0) > 0)
46
- lines.push('[' +
47
- String(value.otherPages) +
48
- ' matching ' +
49
- (kind === 'console' ? 'entries' : 'requests') +
50
- ' from other tabs of the same window not shown — this reports only the tab this session is driving]');
51
- const entries = value.entries ?? [];
52
- for (const entry of entries) {
53
- if (kind === 'console') {
54
- lines.push('#' +
55
- String(entry.n) +
56
- ' [' +
57
- String(entry.level) +
58
- '] ' +
59
- String(entry.text) +
60
- (entry.source ? ' <- ' + String(entry.source) : ''));
61
- }
62
- else {
63
- const outcome = entry.failure !== null && entry.failure !== undefined
64
- ? 'FAILED ' + String(entry.failure)
65
- : String(entry.status === null ? 'pending' : entry.status);
66
- lines.push('#' +
67
- String(entry.n) +
68
- ' ' +
69
- String(entry.method) +
70
- ' ' +
71
- outcome +
72
- ' ' +
73
- String(entry.kind) +
74
- (entry.ms === null ? '' : ' ' + String(entry.ms) + 'ms') +
75
- ' ' +
76
- String(entry.url));
77
- }
78
- }
79
- if (entries.length === 0)
80
- lines.push('(nothing captured yet)');
81
- return lines.join('\n');
82
- }
83
- const SNAPSHOT_SCHEMA = {
84
- type: 'object',
85
- properties: {
86
- url: { type: 'string' },
87
- title: { type: 'string' },
88
- refs: { type: 'integer' },
89
- truncated: { type: 'boolean' },
90
- tree: { type: 'string' },
91
- },
92
- additionalProperties: false,
93
- };
94
- /**
95
- * Detect anti-bot / human-verification challenge pages (Cloudflare
96
- * interstitial, Turnstile, reCAPTCHA-style) from a snapshot's title, url and
97
- * visible text. We do NOT try to solve them: the correct move for an agent is
98
- * to hand the challenge to the human who can see the open window. Returns the
99
- * engine name when matched, else null. Matching is conservative to avoid
100
- * false positives on ordinary pages.
101
- */
102
- export function detectChallenge(snapshot) {
103
- const title = snapshot.title ?? '';
104
- const url = snapshot.url ?? '';
105
- const parts = [];
106
- const walk = (nodes, depth) => {
107
- for (const node of nodes) {
108
- if (parts.length >= 60)
109
- return;
110
- if (node.name !== undefined && node.name !== '')
111
- parts.push(node.name);
112
- if (depth < 2 && node.children !== undefined && node.children.length > 0)
113
- walk(node.children, depth + 1);
114
- }
115
- };
116
- walk(snapshot.nodes ?? [], 0);
117
- const text = [title, url, ...parts].join(' | ').slice(0, 900);
118
- const saidCaptcha = parts.some((name) => /(captcha|turnstile|recaptcha|hcaptcha)/i.test(name));
119
- if (/just a moment|attention required|checking (your )?browser|cf-chl-|请稍候|验证你的浏览器/i.test(text))
120
- return { engine: 'Cloudflare', hint: 'Cloudflare is challenging the browser before letting the page load' };
121
- if (/verify (you are|you're|that you are) (a )?human|人机验证|验证您(是|为)人类/i.test(text))
122
- return { engine: 'human-verification', hint: 'the page is asking to verify a human visitor' };
123
- if (/(captcha|turnstile|recaptcha|hcaptcha)/i.test(text) &&
124
- (saidCaptcha || /(captcha|turnstile|recaptcha)/i.test(title)))
125
- return { engine: 'captcha', hint: 'the page embedded a captcha-style challenge' };
126
- return null;
127
- }
128
- /**
129
- * Instruction appended above a snapshot when the page is an anti-bot
130
- * challenge. The browser window is visible on the user's desktop: the agent
131
- * should pause and ask the user to complete it by hand, then re-snapshot.
132
- */
133
- const CHALLENGE_NOTE = "[human verification] The page triggered an anti-bot challenge; it is NOT solvable by automation and must NOT be retried blindly. The browser window is open on the user's desktop — tell the user a verification appeared, wait for them to finish it (a few seconds), then call browser_snapshot again. The clearance cookie is saved to the profile automatically and keeps working for the session.";
134
- /** Project a provider snapshot into its canonical tool value. */
135
- function snapshotValue(snapshot) {
136
- const challenge = detectChallenge(snapshot);
137
- const tree = renderSnapshot(snapshot);
138
- const notes = [];
139
- const dialog = snapshot.dialogNote;
140
- if (dialog !== undefined && dialog !== '')
141
- notes.push(dialog);
142
- const landing = snapshot.landingNote;
143
- if (landing !== undefined && landing !== '')
144
- notes.push(landing);
145
- // Engine fallback disclosure. The user explicitly chose a page-reading mode in
146
- // Settings; when the provider has to serve the other engine's tree, saying so
147
- // is the difference between a checked assumption and a silent one.
148
- const engine = snapshot.engineNote;
149
- if (engine !== undefined && engine !== '')
150
- notes.push('[page reader] ' + engine);
151
- const head = notes.length > 0 ? notes.join('\n\n') + '\n\n' : '';
152
- // Incremental diff rides along when the provider computed one (snapshot.diff).
153
- const diffBlock = snapshot.diff !== undefined ? '\n\n' + renderSnapshotDiff(snapshot.diff) : '';
154
- return {
155
- url: snapshot.url,
156
- title: snapshot.title,
157
- refs: snapshot.totalRefs,
158
- truncated: snapshot.truncated,
159
- tree: head +
160
- (challenge === null ? tree : challenge.engine + ': ' + challenge.hint + '.\n' + CHALLENGE_NOTE + '\n\n' + tree) +
161
- diffBlock,
162
- };
163
- }
164
- /** Render a snapshot value as one text block. */
165
- function renderSnapshotValue(_args, value) {
166
- return [{ type: 'text', text: value.tree }];
167
- }
168
- const TABS_SCHEMA = {
169
- type: 'object',
170
- properties: {
171
- tabs: {
172
- type: 'array',
173
- items: {
174
- type: 'object',
175
- properties: {
176
- index: { type: 'integer' },
177
- url: { type: 'string' },
178
- title: { type: 'string' },
179
- active: { type: 'boolean' },
180
- },
181
- additionalProperties: false,
182
- },
183
- },
184
- },
185
- additionalProperties: false,
186
- };
187
- function renderTabsValue(_args, value) {
188
- if (value.tabs.length === 0)
189
- return [{ type: 'text', text: 'No open tabs.' }];
190
- const lines = value.tabs.map((tab) => String(tab.index) + '.' + (tab.active ? ' [active]' : '') + ' ' + (tab.title || tab.url));
191
- return [{ type: 'text', text: lines.join('\n') }];
192
- }
193
- const SCREENSHOT_SCHEMA = {
194
- type: 'object',
195
- properties: {
196
- attachmentId: { type: 'string' },
197
- mediaType: { type: 'string' },
198
- bytes: { type: 'integer' },
199
- width: { type: 'integer' },
200
- height: { type: 'integer' },
201
- url: { type: 'string' },
202
- },
203
- additionalProperties: false,
204
- };
205
- const SCREENSHOT_EXTENSIONS = {
206
- 'image/png': '.png',
207
- 'image/jpeg': '.jpg',
208
- 'image/webp': '.webp',
209
- 'image/gif': '.gif',
210
- };
211
- /** Render a screenshot as its summary plus the image block itself. */
212
- function renderScreenshotValue(_args, value) {
213
- // The attachment store normalizes a capture before storing it: a metadata-carrying PNG
214
- // is re-encoded to JPEG (no alpha) or WebP (alpha). The durable reference must therefore
215
- // carry the media type the store returned -- hardcoding 'image/png' here makes every
216
- // later request containing this message fail with ATTACHMENT_CORRUPT before the model
217
- // call is even attempted (surfacing as a bogus transport error).
218
- const mediaType = typeof value.mediaType === 'string' && value.mediaType.length > 0 ? value.mediaType : 'image/png';
219
- const summary = 'Screenshot of ' +
220
- value.url +
221
- ': ' +
222
- value.width +
223
- 'x' +
224
- value.height +
225
- ' px, ' +
226
- value.bytes +
227
- ' bytes (' +
228
- mediaType.slice('image/'.length).toUpperCase() +
229
- ' attachment).';
230
- // The store brands its id and media type; the canonical value carries plain
231
- // strings, so the assertion happens once, here, at the boundary.
232
- const attachment = {
233
- attachmentId: value.attachmentId,
234
- mediaType,
235
- bytes: value.bytes,
236
- width: value.width,
237
- height: value.height,
238
- name: 'browser-screenshot' + (SCREENSHOT_EXTENSIONS[mediaType] ?? '.png'),
239
- };
240
- return [
241
- { type: 'text', text: summary },
242
- { type: 'image', attachment },
243
- ];
244
- }
245
- /** Strip markdown fences and whitespace, then parse one JSON document. */
246
- function parseJsonText(raw) {
247
- let text = raw.trim();
248
- const fenced = /^\s*```(?:json)?\s*([\s\S]*?)```\s*$/.exec(text);
249
- if (fenced !== null) {
250
- const inner = fenced[1];
251
- if (inner !== undefined)
252
- text = inner.trim();
253
- }
254
- try {
255
- return JSON.parse(text);
256
- }
257
- catch {
258
- throw new Error('browser_extract: the model output is not valid JSON: ' + text.slice(0, 500));
259
- }
260
- }
261
- /**
262
- * Prompt template for structured page extraction. `maxInputChars` bounds the page
263
- * data handed to the auxiliary model — that model is billed on this prompt, so an
264
- * unbounded page is a tax on every extraction.
265
- */
266
- function extractionPrompt(instruction, data, maxInputChars) {
267
- const page = JSON.stringify(data);
268
- const bounded = page.length > maxInputChars
269
- ? page.slice(0, maxInputChars) + ' [page data truncated at ' + String(maxInputChars) + ' characters]'
270
- : page;
271
- return ('Extract the following from the page data according to the instruction. ' +
272
- 'Answer with ONLY one valid JSON value (object, array, or scalar), no prose, no markdown fences.\n\n' +
273
- 'Instruction: ' +
274
- instruction +
275
- '\n\n' +
276
- 'Page data (JSON):\n' +
277
- bounded);
278
- }
279
32
  /**
280
33
  * Register the browser tool family on ctx.tools.
281
34
  * @param ctx - plugin context carrying browser, tools, and the optional services.
@@ -0,0 +1,2 @@
1
+ /** Validate one absolute URL against the navigation policy. */
2
+ export declare function assertAllowedUrl(raw: string, allowedDomains: readonly string[]): URL;