@mayurajs/cloudflare-quick-action 0.0.0-stage → 1.1.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/LICENSE +201 -0
- package/NOTICE +8 -0
- package/README.md +50 -2
- package/dist/client.d.ts +93 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +134 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/tools.d.ts +42 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +197 -0
- package/dist/tools.js.map +1 -0
- package/package.json +43 -4
- package/src/client.ts +162 -0
- package/src/index.ts +4 -0
- package/src/tools.ts +201 -0
package/src/tools.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { defineTool, media, MayuraError, withMedia, type AnyTool, type JsonObject, type Schema } from 'mayura';
|
|
2
|
+
import { originPolicy } from 'mayura/browser';
|
|
3
|
+
import type { QuickActions } from './client.js';
|
|
4
|
+
|
|
5
|
+
export interface QuickActionToolsOptions {
|
|
6
|
+
/** Names the tools (`<name>.markdown`, ...) and their permissions (`web:<name>:read`, ...); `cloudflare` by default. */
|
|
7
|
+
readonly name?: string;
|
|
8
|
+
/**
|
|
9
|
+
* The sites the tools may read, as `createBrowsers` takes them: `['https://example.com', 'https://*.example.com']`,
|
|
10
|
+
* or `'all'`. Required: nothing is allowed by default. The page Cloudflare renders loads nothing from other sites.
|
|
11
|
+
*/
|
|
12
|
+
readonly origins: 'all' | readonly string[];
|
|
13
|
+
/** Make `<name>.screenshot`, an image of a page; permission `web:<name>:read`. Off by default. */
|
|
14
|
+
readonly screenshot?: boolean;
|
|
15
|
+
/** Make `<name>.json`, structured data from a page by Cloudflare's model (Workers AI); permission `web:<name>:extract`. Off by default. */
|
|
16
|
+
readonly json?: boolean;
|
|
17
|
+
/** Make `<name>.crawl`, which reads many pages of a site; permission `web:<name>:crawl`. Off by default. */
|
|
18
|
+
readonly crawl?: boolean;
|
|
19
|
+
/** The most bytes of a page's content returned to the model; 64 KiB by default. */
|
|
20
|
+
readonly maxPageBytes?: number;
|
|
21
|
+
/** The most pages one crawl reads; 10 by default (at most 100). */
|
|
22
|
+
readonly maxCrawlPages?: number;
|
|
23
|
+
/** The longest one crawl runs before it is cancelled; 5 minutes by default. */
|
|
24
|
+
readonly crawlTimeoutMs?: number;
|
|
25
|
+
/** What each call costs at most, in micro-units of your budget currency, by tool; 0 by default. */
|
|
26
|
+
readonly costMicros?: { readonly markdown?: number; readonly links?: number; readonly scrape?: number; readonly screenshot?: number; readonly json?: number; readonly crawl?: number };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Kind = 'string' | 'integer?' | 'boolean?' | 'string?' | 'object?' | 'strings';
|
|
30
|
+
function object<T>(fields: Readonly<Record<string, Kind>>, maxString: number, check?: (value: T) => string | undefined): Schema<T> {
|
|
31
|
+
return { '~standard': { version: 1, vendor: 'mayura-cloudflare', validate: (value: unknown) => {
|
|
32
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return { issues: [{ message: 'Expected an object.' }] };
|
|
33
|
+
const input = value as Record<string, unknown>;
|
|
34
|
+
for (const name of Object.keys(input)) if (!Object.hasOwn(fields, name)) return { issues: [{ message: `Unexpected field ${name}.` }] };
|
|
35
|
+
for (const [name, kind] of Object.entries(fields)) {
|
|
36
|
+
const item = input[name];
|
|
37
|
+
if (item === undefined) { if (kind.endsWith('?')) continue; return { issues: [{ message: `${name} is required.` }] }; }
|
|
38
|
+
if (kind.startsWith('string') && kind !== 'strings' && (typeof item !== 'string' || item.length > maxString)) return { issues: [{ message: `${name} must be a string.` }] };
|
|
39
|
+
if (kind === 'strings' && (!Array.isArray(item) || item.length === 0 || item.length > 20 || item.some(entry => typeof entry !== 'string' || entry === '' || entry.length > 500))) return { issues: [{ message: `${name} is 1 to 20 strings.` }] };
|
|
40
|
+
if (kind.startsWith('integer') && (typeof item !== 'number' || !Number.isSafeInteger(item) || item < 1)) return { issues: [{ message: `${name} must be a positive whole number.` }] };
|
|
41
|
+
if (kind.startsWith('boolean') && typeof item !== 'boolean') return { issues: [{ message: `${name} must be true or false.` }] };
|
|
42
|
+
if (kind.startsWith('object') && (!item || typeof item !== 'object' || Array.isArray(item) || JSON.stringify(item).length > 20_000)) return { issues: [{ message: `${name} must be a JSON Schema object.` }] };
|
|
43
|
+
}
|
|
44
|
+
const message = check?.(input as T);
|
|
45
|
+
return message === undefined ? { value: input as T } : { issues: [{ message }] };
|
|
46
|
+
} } } as Schema<T>;
|
|
47
|
+
}
|
|
48
|
+
const anything: Schema<unknown> = { '~standard': { version: 1, vendor: 'mayura-cloudflare', validate: (value: unknown) => ({ value }) } } as Schema<unknown>;
|
|
49
|
+
const schema = (required: readonly string[], properties: Record<string, unknown>) => ({ type: 'object', additionalProperties: false, ...(required.length ? { required: [...required] } : {}), properties }) as unknown as JsonObject;
|
|
50
|
+
const encoder = new TextEncoder(); const decoder = new TextDecoder();
|
|
51
|
+
function clip(text: string, max: number): { text: string; truncated: boolean } {
|
|
52
|
+
const bytes = encoder.encode(text);
|
|
53
|
+
return bytes.byteLength <= max ? { text, truncated: false } : { text: decoder.decode(bytes.subarray(0, max)).replace(/�$/u, ''), truncated: true };
|
|
54
|
+
}
|
|
55
|
+
const correctable = new Set(['INVALID_INPUT', 'PERMISSION_DENIED', 'LIMIT_EXCEEDED']);
|
|
56
|
+
const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\/]/gu, '\\$&');
|
|
57
|
+
|
|
58
|
+
/** Request patterns that let a page load only from the origins (already checked by originPolicy). */
|
|
59
|
+
export function originPatterns(origins: readonly string[]): string[] {
|
|
60
|
+
return origins.map(origin => {
|
|
61
|
+
const match = /^(https?):\/\/(\*\.)?([^/:]+|\[[^\]]+\])(?::(\d{1,5}))?\/?$/u.exec(origin.toLowerCase())!;
|
|
62
|
+
const port = match[4] ? `:${match[4]}` : `(:${match[1] === 'https' ? 443 : 80})?`;
|
|
63
|
+
return `^${match[1]}://${match[2] ? '([a-z0-9-]+\\.)+' : ''}${escape(match[3]!)}${port}(/|\\?|#|$)`;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Cloudflare Browser Run's quick actions as tools: `<name>.markdown`, `<name>.links` and `<name>.scrape` read pages
|
|
69
|
+
* (`web:<name>:read`); screenshots, JSON extraction and crawls are each off until enabled. Every URL must be within
|
|
70
|
+
* `origins`, and the page Cloudflare renders loads nothing from elsewhere. Page content comes back as the tools'
|
|
71
|
+
* results, for the model to read: treat it as untrusted text.
|
|
72
|
+
*/
|
|
73
|
+
export function quickActionTools(actions: QuickActions, options: QuickActionToolsOptions): AnyTool[] {
|
|
74
|
+
if (!actions || typeof actions.markdown !== 'function') throw new MayuraError('INVALID_CONFIG', 'quickActionTools() needs quick actions from cloudflareQuickActions().');
|
|
75
|
+
const name = options?.name ?? 'cloudflare';
|
|
76
|
+
if (typeof name !== 'string' || !/^[a-z][a-z0-9_-]{0,31}$/u.test(name)) throw new MayuraError('INVALID_CONFIG', 'quickActionTools(): name is lowercase letters, digits, _ and -, starting with a letter.');
|
|
77
|
+
const policy = originPolicy(options.origins);
|
|
78
|
+
const allowRequestPattern = policy.all ? undefined : originPatterns(options.origins as readonly string[]);
|
|
79
|
+
for (const flag of ['screenshot', 'json', 'crawl'] as const) {
|
|
80
|
+
if (options[flag] !== undefined && typeof options[flag] !== 'boolean') throw new MayuraError('INVALID_CONFIG', `quickActionTools(): ${flag} must be a boolean.`);
|
|
81
|
+
}
|
|
82
|
+
const bound = (value: number | undefined, label: string, fallback: number, min: number, max: number) => {
|
|
83
|
+
const result = value ?? fallback;
|
|
84
|
+
if (!Number.isSafeInteger(result) || result < min || result > max) throw new MayuraError('INVALID_CONFIG', `quickActionTools(): ${label} is ${min} to ${max}.`);
|
|
85
|
+
return result;
|
|
86
|
+
};
|
|
87
|
+
const maxPageBytes = bound(options.maxPageBytes, 'maxPageBytes', 65_536, 1_024, 4_194_304);
|
|
88
|
+
const maxCrawlPages = bound(options.maxCrawlPages, 'maxCrawlPages', 10, 1, 100);
|
|
89
|
+
const crawlTimeoutMs = bound(options.crawlTimeoutMs, 'crawlTimeoutMs', 300_000, 10_000, 3_600_000);
|
|
90
|
+
const costs = options.costMicros ?? {};
|
|
91
|
+
for (const [tool, cost] of Object.entries(costs)) if (!Number.isSafeInteger(cost) || (cost as number) < 0) throw new MayuraError('INVALID_CONFIG', `quickActionTools(): costMicros.${tool} must be a non-negative whole number.`);
|
|
92
|
+
|
|
93
|
+
/** The page request for a URL the tools may read, or a refusal the model can act on. */
|
|
94
|
+
const page = (url: unknown) => {
|
|
95
|
+
let parsed: URL;
|
|
96
|
+
try { parsed = new URL(String(url)); } catch { throw new MayuraError('INVALID_INPUT', 'url must be an http(s) URL.'); }
|
|
97
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new MayuraError('INVALID_INPUT', 'url must be an http(s) URL.');
|
|
98
|
+
if (!policy.allows(parsed.href)) throw new MayuraError('PERMISSION_DENIED', `${parsed.origin} is outside the sites these tools may read.`);
|
|
99
|
+
return { url: parsed.href, ...(allowRequestPattern ? { allowRequestPattern } : {}) };
|
|
100
|
+
};
|
|
101
|
+
const tool = (id: string, permission: string, cost: number | undefined, description: string, input: Schema<unknown>, inputJsonSchema: JsonObject,
|
|
102
|
+
execute: (request: Record<string, unknown>, signal: AbortSignal) => Promise<JsonObject | ReturnType<typeof withMedia>>, extra: Record<string, unknown> = {}, timeoutMs = 120_000) =>
|
|
103
|
+
defineTool({ id: `${name}.${id}`, version: '1', effects: 'read', capabilities: [`web:${name}:${permission}`], timeoutMs, costMicros: cost ?? 0,
|
|
104
|
+
description, input, output: anything, inputJsonSchema, ...extra,
|
|
105
|
+
execute: async (request: unknown, context: { readonly signal: AbortSignal }) => {
|
|
106
|
+
try { return await execute(request as Record<string, unknown>, context.signal); }
|
|
107
|
+
catch (error) {
|
|
108
|
+
// Mistakes the model can put right come back as the result, so it can try again; Cloudflare's failures are thrown.
|
|
109
|
+
if (error instanceof MayuraError && correctable.has(error.code)) return { error: error.code, message: error.message } as JsonObject;
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
} } as never) as unknown as AnyTool;
|
|
113
|
+
const urlField = { type: 'string', description: 'An http(s) URL.' };
|
|
114
|
+
|
|
115
|
+
const tools: AnyTool[] = [
|
|
116
|
+
tool('markdown', 'read', costs.markdown, `Read a web page, rendered by Cloudflare's browser, as markdown. Only some sites may be read. Up to ${maxPageBytes} bytes.`,
|
|
117
|
+
object<{ url: string }>({ url: 'string' }, 8_192), schema(['url'], { url: urlField }),
|
|
118
|
+
async (request, signal) => {
|
|
119
|
+
const target = page(request['url']); const content = clip(await actions.markdown(target, { signal }), maxPageBytes);
|
|
120
|
+
return { url: target.url, markdown: content.text, ...(content.truncated ? { truncated: true } : {}) } as JsonObject;
|
|
121
|
+
}),
|
|
122
|
+
tool('links', 'read', costs.links, 'List the links on a web page, rendered by Cloudflare\'s browser.',
|
|
123
|
+
object<{ url: string; visibleOnly?: boolean; excludeExternal?: boolean }>({ url: 'string', visibleOnly: 'boolean?', excludeExternal: 'boolean?' }, 8_192),
|
|
124
|
+
schema(['url'], { url: urlField, visibleOnly: { type: 'boolean' }, excludeExternal: { type: 'boolean' } }),
|
|
125
|
+
async (request, signal) => {
|
|
126
|
+
const target = page(request['url']);
|
|
127
|
+
const links = await actions.links({ ...target, visibleLinksOnly: request['visibleOnly'] === true, excludeExternalLinks: request['excludeExternal'] === true }, { signal });
|
|
128
|
+
return { url: target.url, links: links.slice(0, 1_000) } as unknown as JsonObject;
|
|
129
|
+
}),
|
|
130
|
+
tool('scrape', 'read', costs.scrape, 'Read the elements of a web page that match CSS selectors: their text and attributes.',
|
|
131
|
+
object<{ url: string; selectors: string[] }>({ url: 'string', selectors: 'strings' }, 8_192),
|
|
132
|
+
schema(['url', 'selectors'], { url: urlField, selectors: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 20, description: 'CSS selectors, such as h1 or a.product-link.' } }),
|
|
133
|
+
async (request, signal) => {
|
|
134
|
+
const target = page(request['url']);
|
|
135
|
+
const found = await actions.scrape({ ...target, selectors: request['selectors'] as string[] }, { signal });
|
|
136
|
+
let budget = maxPageBytes; let truncated = false;
|
|
137
|
+
const elements = found.map(group => ({ selector: group.selector, results: group.results.flatMap(element => {
|
|
138
|
+
const text = clip(element.text, Math.max(0, budget)); budget -= encoder.encode(text.text).byteLength;
|
|
139
|
+
if (text.truncated) truncated = true;
|
|
140
|
+
return budget < 0 || (text.text === '' && element.text !== '') ? [] : [{ text: text.text, attributes: element.attributes.slice(0, 20) }];
|
|
141
|
+
}) }));
|
|
142
|
+
return { url: target.url, elements, ...(truncated ? { truncated: true } : {}) } as unknown as JsonObject;
|
|
143
|
+
}),
|
|
144
|
+
];
|
|
145
|
+
if (options.screenshot) {
|
|
146
|
+
tools.push(tool('screenshot', 'read', costs.screenshot, 'See a web page, rendered by Cloudflare\'s browser, as an image: the visible part, or (fullPage) all of it.',
|
|
147
|
+
object<{ url: string; fullPage?: boolean }>({ url: 'string', fullPage: 'boolean?' }, 8_192), schema(['url'], { url: urlField, fullPage: { type: 'boolean' } }),
|
|
148
|
+
async (request, signal) => {
|
|
149
|
+
const target = page(request['url']);
|
|
150
|
+
const image = await actions.screenshot({ ...target, fullPage: request['fullPage'] === true }, { signal });
|
|
151
|
+
if (image.byteLength > 16 * 1_048_576) throw new MayuraError('LIMIT_EXCEEDED', 'The screenshot is larger than 16 MiB.');
|
|
152
|
+
return withMedia({ url: target.url, bytes: image.byteLength } as JsonObject, [media(image, 'image/png')]);
|
|
153
|
+
}, { media: { accept: ['image/png'], maxItems: 1, maxBytes: 16 * 1_048_576 } }));
|
|
154
|
+
}
|
|
155
|
+
if (options.json) {
|
|
156
|
+
tools.push(tool('json', 'extract', costs.json, 'Pull structured data out of a web page with Cloudflare\'s model: say what to extract in prompt, and optionally give a JSON Schema for its shape.',
|
|
157
|
+
object<{ url: string; prompt: string; schema?: Record<string, unknown> }>({ url: 'string', prompt: 'string', schema: 'object?' }, 8_192, request => request.prompt.trim() === '' ? 'prompt is required.' : undefined),
|
|
158
|
+
schema(['url', 'prompt'], { url: urlField, prompt: { type: 'string' }, schema: { type: 'object', description: 'A JSON Schema for the data.' } }),
|
|
159
|
+
async (request, signal) => {
|
|
160
|
+
const target = page(request['url']);
|
|
161
|
+
const data = await actions.json({ ...target, prompt: request['prompt'] as string, ...(request['schema'] ? { schema: request['schema'] as Record<string, unknown> } : {}) }, { signal });
|
|
162
|
+
if (encoder.encode(JSON.stringify(data ?? null)).byteLength > maxPageBytes) throw new MayuraError('LIMIT_EXCEEDED', `The extracted data is larger than ${maxPageBytes} bytes.`);
|
|
163
|
+
return { url: target.url, data: (data ?? null) as JsonObject } as JsonObject;
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
if (options.crawl) {
|
|
167
|
+
tools.push(tool('crawl', 'crawl', costs.crawl, `Read up to ${maxCrawlPages} pages of a site, starting at a URL and following its links on the same site, as markdown, rendered by Cloudflare's browser.`,
|
|
168
|
+
object<{ url: string; limit?: number; includePattern?: string }>({ url: 'string', limit: 'integer?', includePattern: 'string?' }, 8_192, request => (request.limit ?? 1) > maxCrawlPages ? `limit is at most ${maxCrawlPages}.` : undefined),
|
|
169
|
+
schema(['url'], { url: urlField, limit: { type: 'integer', minimum: 1, maximum: maxCrawlPages }, includePattern: { type: 'string', description: 'A wildcard pattern the pages to read must match, such as https://example.com/docs/**.' } }),
|
|
170
|
+
async (request, signal) => {
|
|
171
|
+
const target = page(request['url']); const limit = (request['limit'] as number | undefined) ?? maxCrawlPages;
|
|
172
|
+
const id = await actions.crawl.start({ ...target, limit, formats: ['markdown'], ...(request['includePattern'] ? { includePatterns: [request['includePattern'] as string] } : {}) }, { signal });
|
|
173
|
+
const deadline = Date.now() + crawlTimeoutMs; let finished = false;
|
|
174
|
+
try {
|
|
175
|
+
for (;;) {
|
|
176
|
+
const status = await actions.crawl.status(id, { signal, limit });
|
|
177
|
+
if (status.status !== 'running') {
|
|
178
|
+
finished = true;
|
|
179
|
+
if (status.status !== 'completed') throw new MayuraError('TOOL_FAILED', `Cloudflare's crawl ended without completing (${status.status}).`);
|
|
180
|
+
// Only pages read, and within the sites allowed.
|
|
181
|
+
const pages = status.records.filter(record => record.status === 'completed' && policy.allows(record.url)).slice(0, limit).map(record => {
|
|
182
|
+
const content = clip(record.markdown ?? '', maxPageBytes);
|
|
183
|
+
return { url: record.url, ...(record.title ? { title: record.title.slice(0, 500) } : {}), markdown: content.text, ...(content.truncated ? { truncated: true } : {}) };
|
|
184
|
+
});
|
|
185
|
+
return { url: target.url, pages } as unknown as JsonObject;
|
|
186
|
+
}
|
|
187
|
+
if (Date.now() >= deadline) throw new MayuraError('TIMEOUT', 'The crawl took too long, and was cancelled.');
|
|
188
|
+
await new Promise((resolve, reject) => {
|
|
189
|
+
const stop = () => { clearTimeout(timer); reject(new MayuraError('CANCELLED', 'The crawl was cancelled.')); };
|
|
190
|
+
const timer = setTimeout(() => { signal.removeEventListener('abort', stop); resolve(undefined); }, 2_000);
|
|
191
|
+
if (signal.aborted) stop(); else signal.addEventListener('abort', stop, { once: true });
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
} finally {
|
|
195
|
+
// A crawl not finished is cancelled, so it stops spending.
|
|
196
|
+
if (!finished) await actions.crawl.cancel(id, { signal: AbortSignal.timeout(30_000) }).catch(() => undefined);
|
|
197
|
+
}
|
|
198
|
+
}, {}, crawlTimeoutMs + 60_000));
|
|
199
|
+
}
|
|
200
|
+
return tools;
|
|
201
|
+
}
|