@humanjs/mcp 0.4.3 → 0.5.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/README.md +18 -3
- package/dist/index.cjs +435 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +435 -31
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -74,6 +74,197 @@ function parseViewport(raw) {
|
|
|
74
74
|
}
|
|
75
75
|
return { width: Number(match[1]), height: Number(match[2]) };
|
|
76
76
|
}
|
|
77
|
+
|
|
78
|
+
// src/observability.ts
|
|
79
|
+
var RingBuffer = class {
|
|
80
|
+
constructor(limit) {
|
|
81
|
+
this.limit = limit;
|
|
82
|
+
if (limit < 1) throw new Error(`RingBuffer limit must be >= 1, got ${limit}`);
|
|
83
|
+
}
|
|
84
|
+
limit;
|
|
85
|
+
items = [];
|
|
86
|
+
droppedCount = 0;
|
|
87
|
+
push(item) {
|
|
88
|
+
this.items.push(item);
|
|
89
|
+
if (this.items.length > this.limit) {
|
|
90
|
+
this.items.shift();
|
|
91
|
+
this.droppedCount += 1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
toArray() {
|
|
95
|
+
return this.items;
|
|
96
|
+
}
|
|
97
|
+
/** Entries evicted because the buffer was full. */
|
|
98
|
+
get dropped() {
|
|
99
|
+
return this.droppedCount;
|
|
100
|
+
}
|
|
101
|
+
get size() {
|
|
102
|
+
return this.items.length;
|
|
103
|
+
}
|
|
104
|
+
clear() {
|
|
105
|
+
this.items = [];
|
|
106
|
+
this.droppedCount = 0;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
var CONSOLE_BUFFER_LIMIT = 500;
|
|
110
|
+
var NETWORK_BUFFER_LIMIT = 500;
|
|
111
|
+
var ERROR_LEVELS = /* @__PURE__ */ new Set(["error", "pageerror"]);
|
|
112
|
+
var MAX_PATTERN_LENGTH = 200;
|
|
113
|
+
function compilePattern(pattern) {
|
|
114
|
+
if (pattern.length > MAX_PATTERN_LENGTH) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`\`pattern\` is ${pattern.length} characters; the limit is ${MAX_PATTERN_LENGTH}. Filter on something short and distinctive ("cors", "\\.png$") and narrow further with \`onlyErrors\`/\`status\` rather than one long alternation.`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
return new RegExp(pattern, "i");
|
|
121
|
+
} catch (error) {
|
|
122
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Invalid \`pattern\` regular expression: ${detail}. Pass a valid JS regex (it is matched case-insensitively), or omit \`pattern\` to see everything.`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function queryConsole(buffer, query) {
|
|
129
|
+
const all = buffer.toArray();
|
|
130
|
+
const regex = query.pattern ? compilePattern(query.pattern) : null;
|
|
131
|
+
const matches = all.filter((entry) => {
|
|
132
|
+
if (query.onlyErrors && !ERROR_LEVELS.has(entry.level)) return false;
|
|
133
|
+
if (regex && !regex.test(entry.text)) return false;
|
|
134
|
+
return true;
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
entries: takeLast(matches, query.limit),
|
|
138
|
+
matched: matches.length,
|
|
139
|
+
total: all.length,
|
|
140
|
+
dropped: buffer.dropped
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function queryNetwork(buffer, query) {
|
|
144
|
+
const all = buffer.toArray();
|
|
145
|
+
const regex = query.pattern ? compilePattern(query.pattern) : null;
|
|
146
|
+
const matches = all.filter((entry) => {
|
|
147
|
+
if (query.onlyFailures && !isFailure(entry)) return false;
|
|
148
|
+
if (query.status !== void 0 && entry.status !== query.status) return false;
|
|
149
|
+
if (regex && !regex.test(entry.url)) return false;
|
|
150
|
+
return true;
|
|
151
|
+
});
|
|
152
|
+
return {
|
|
153
|
+
entries: takeLast(matches, query.limit),
|
|
154
|
+
matched: matches.length,
|
|
155
|
+
total: all.length,
|
|
156
|
+
dropped: buffer.dropped
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function isFailure(entry) {
|
|
160
|
+
if (entry.failure !== null) return true;
|
|
161
|
+
return entry.status !== null && entry.status >= 400;
|
|
162
|
+
}
|
|
163
|
+
function takeLast(items, limit) {
|
|
164
|
+
if (limit === void 0 || items.length <= limit) return items;
|
|
165
|
+
return items.slice(items.length - limit);
|
|
166
|
+
}
|
|
167
|
+
function formatConsole(result) {
|
|
168
|
+
if (result.entries.length === 0) return emptyMessage("console messages", result);
|
|
169
|
+
const lines = result.entries.map((entry) => {
|
|
170
|
+
const where = entry.source ? ` (${entry.source})` : "";
|
|
171
|
+
return `[${entry.level}] ${entry.text}${where}`;
|
|
172
|
+
});
|
|
173
|
+
return [...lines, "", summary("message", result)].join("\n");
|
|
174
|
+
}
|
|
175
|
+
function formatNetwork(result) {
|
|
176
|
+
if (result.entries.length === 0) return emptyMessage("network requests", result);
|
|
177
|
+
const lines = result.entries.map((entry) => {
|
|
178
|
+
const status = entry.failure !== null ? `FAILED (${entry.failure})` : String(entry.status);
|
|
179
|
+
const timing = entry.ms === null ? "" : ` ${entry.ms}ms`;
|
|
180
|
+
return `${status} ${entry.method} ${entry.url} [${entry.resourceType}]${timing}`;
|
|
181
|
+
});
|
|
182
|
+
return [...lines, "", summary("request", result)].join("\n");
|
|
183
|
+
}
|
|
184
|
+
function emptyMessage(noun, result) {
|
|
185
|
+
if (result.total === 0 && result.dropped === 0) {
|
|
186
|
+
return `No ${noun} captured yet for this session.`;
|
|
187
|
+
}
|
|
188
|
+
const dropped = result.dropped > 0 ? ` ${result.dropped} older ${noun} were dropped from the buffer, so this is not proof they never happened.` : "";
|
|
189
|
+
return `No ${noun} matched the filter (${result.total} in the buffer).${dropped}`;
|
|
190
|
+
}
|
|
191
|
+
function summary(noun, result) {
|
|
192
|
+
const shown = result.entries.length < result.matched ? `showing the ${result.entries.length} most recent of ${result.matched} matching ${noun}s` : `${result.matched} matching ${noun}${result.matched === 1 ? "" : "s"}`;
|
|
193
|
+
const dropped = result.dropped > 0 ? `; ${result.dropped} older dropped (buffer full)` : "";
|
|
194
|
+
return `\u2014 ${shown} of ${result.total} captured${dropped}`;
|
|
195
|
+
}
|
|
196
|
+
function attachObservers(page) {
|
|
197
|
+
const consoleBuffer = new RingBuffer(CONSOLE_BUFFER_LIMIT);
|
|
198
|
+
const networkBuffer = new RingBuffer(NETWORK_BUFFER_LIMIT);
|
|
199
|
+
const startedAt = /* @__PURE__ */ new WeakMap();
|
|
200
|
+
const onConsole = (message) => {
|
|
201
|
+
const location = message.location();
|
|
202
|
+
const source = location.url ? `${location.url}:${location.lineNumber}:${location.columnNumber}` : null;
|
|
203
|
+
consoleBuffer.push({
|
|
204
|
+
level: message.type(),
|
|
205
|
+
text: message.text(),
|
|
206
|
+
source,
|
|
207
|
+
at: Date.now()
|
|
208
|
+
});
|
|
209
|
+
};
|
|
210
|
+
const onPageError = (error) => {
|
|
211
|
+
consoleBuffer.push({
|
|
212
|
+
level: "pageerror",
|
|
213
|
+
text: error.stack ?? `${error.name}: ${error.message}`,
|
|
214
|
+
source: null,
|
|
215
|
+
at: Date.now()
|
|
216
|
+
});
|
|
217
|
+
};
|
|
218
|
+
const onRequest = (request) => {
|
|
219
|
+
startedAt.set(request, Date.now());
|
|
220
|
+
};
|
|
221
|
+
const onResponse = (response) => {
|
|
222
|
+
const request = response.request();
|
|
223
|
+
const start = startedAt.get(request);
|
|
224
|
+
networkBuffer.push({
|
|
225
|
+
method: request.method(),
|
|
226
|
+
url: response.url(),
|
|
227
|
+
status: response.status(),
|
|
228
|
+
failure: null,
|
|
229
|
+
resourceType: request.resourceType(),
|
|
230
|
+
at: Date.now(),
|
|
231
|
+
ms: start === void 0 ? null : Date.now() - start
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
const onRequestFailed = (request) => {
|
|
235
|
+
const start = startedAt.get(request);
|
|
236
|
+
networkBuffer.push({
|
|
237
|
+
method: request.method(),
|
|
238
|
+
url: request.url(),
|
|
239
|
+
status: null,
|
|
240
|
+
failure: request.failure()?.errorText ?? "request failed",
|
|
241
|
+
resourceType: request.resourceType(),
|
|
242
|
+
at: Date.now(),
|
|
243
|
+
ms: start === void 0 ? null : Date.now() - start
|
|
244
|
+
});
|
|
245
|
+
};
|
|
246
|
+
page.on("console", onConsole);
|
|
247
|
+
page.on("pageerror", onPageError);
|
|
248
|
+
page.on("request", onRequest);
|
|
249
|
+
page.on("response", onResponse);
|
|
250
|
+
page.on("requestfailed", onRequestFailed);
|
|
251
|
+
let detached = false;
|
|
252
|
+
return {
|
|
253
|
+
console: consoleBuffer,
|
|
254
|
+
network: networkBuffer,
|
|
255
|
+
detach: () => {
|
|
256
|
+
if (detached) return;
|
|
257
|
+
detached = true;
|
|
258
|
+
page.off("console", onConsole);
|
|
259
|
+
page.off("pageerror", onPageError);
|
|
260
|
+
page.off("request", onRequest);
|
|
261
|
+
page.off("response", onResponse);
|
|
262
|
+
page.off("requestfailed", onRequestFailed);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// src/session.ts
|
|
77
268
|
var DEFAULT_SESSION_ID = "default";
|
|
78
269
|
var SessionManager = class {
|
|
79
270
|
/** Backing browser for `ephemeral` and `cdp` modes. */
|
|
@@ -133,6 +324,7 @@ var SessionManager = class {
|
|
|
133
324
|
const personality = options.personality ?? this.env.personality;
|
|
134
325
|
const speed = options.speed ?? this.env.speed;
|
|
135
326
|
const human = await playwright.createHuman(page, { personality, speed });
|
|
327
|
+
const observers = attachObservers(page);
|
|
136
328
|
const session = {
|
|
137
329
|
id,
|
|
138
330
|
context,
|
|
@@ -142,6 +334,7 @@ var SessionManager = class {
|
|
|
142
334
|
speed,
|
|
143
335
|
mode: config.mode,
|
|
144
336
|
recording: null,
|
|
337
|
+
observers,
|
|
145
338
|
createdAt: Date.now()
|
|
146
339
|
};
|
|
147
340
|
this.sessions.set(id, session);
|
|
@@ -263,6 +456,7 @@ var SessionManager = class {
|
|
|
263
456
|
async close(id) {
|
|
264
457
|
const session = this.sessions.get(id);
|
|
265
458
|
if (!session) return;
|
|
459
|
+
session.observers.detach();
|
|
266
460
|
if (session.recording) {
|
|
267
461
|
const rec = session.recording;
|
|
268
462
|
session.recording = null;
|
|
@@ -520,6 +714,10 @@ function registerBrowserTools(server, { sessions }) {
|
|
|
520
714
|
);
|
|
521
715
|
}
|
|
522
716
|
var preset = zod.z.enum(["careful", "fast", "distracted", "precise"]);
|
|
717
|
+
function toMediaValue(value) {
|
|
718
|
+
if (value === void 0) return void 0;
|
|
719
|
+
return value === "system" ? null : value;
|
|
720
|
+
}
|
|
523
721
|
function registerConfigTools(server, { sessions }) {
|
|
524
722
|
server.registerTool(
|
|
525
723
|
"human_set_personality",
|
|
@@ -583,6 +781,49 @@ function registerConfigTools(server, { sessions }) {
|
|
|
583
781
|
return { content: [{ type: "text", text: `viewport set to ${width}\xD7${height}` }] };
|
|
584
782
|
}
|
|
585
783
|
);
|
|
784
|
+
server.registerTool(
|
|
785
|
+
"human_emulate_media",
|
|
786
|
+
{
|
|
787
|
+
title: "Emulate media features",
|
|
788
|
+
description: "Emulates CSS media features for a session so you can verify the accessibility and theming paths a real user would get. `reducedMotion: 'reduce'` is the important one: it is the only way to check that a site's prefers-reduced-motion path actually works, and it is normally impossible to test without changing OS settings. Settings persist across navigations until changed; pass 'system' to stop emulating a feature.",
|
|
789
|
+
inputSchema: {
|
|
790
|
+
reducedMotion: zod.z.enum(["reduce", "no-preference", "system"]).optional().describe(
|
|
791
|
+
"Emulate prefers-reduced-motion. Use 'reduce' to verify the reduced-motion path renders and stays usable."
|
|
792
|
+
),
|
|
793
|
+
colorScheme: zod.z.enum(["light", "dark", "no-preference", "system"]).optional().describe("Emulate prefers-color-scheme, to check the light and dark themes."),
|
|
794
|
+
forcedColors: zod.z.enum(["active", "none", "system"]).optional().describe(
|
|
795
|
+
"Emulate forced-colors (Windows High Contrast). 'active' reveals content that disappears when the author's colors are overridden."
|
|
796
|
+
),
|
|
797
|
+
session: zod.z.string().optional().describe("Session ID. Omit for the default session.")
|
|
798
|
+
}
|
|
799
|
+
},
|
|
800
|
+
async ({ reducedMotion, colorScheme, forcedColors, session }) => {
|
|
801
|
+
if (reducedMotion === void 0 && colorScheme === void 0 && forcedColors === void 0) {
|
|
802
|
+
throw new Error(
|
|
803
|
+
'Pass at least one of `reducedMotion`, `colorScheme` or `forcedColors`. Use "system" to stop emulating one.'
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
const { human } = await sessions.get(session);
|
|
807
|
+
await human.emulateMedia({
|
|
808
|
+
reducedMotion: toMediaValue(reducedMotion),
|
|
809
|
+
colorScheme: toMediaValue(colorScheme),
|
|
810
|
+
forcedColors: toMediaValue(forcedColors)
|
|
811
|
+
});
|
|
812
|
+
const applied = [
|
|
813
|
+
reducedMotion && `prefers-reduced-motion: ${reducedMotion}`,
|
|
814
|
+
colorScheme && `prefers-color-scheme: ${colorScheme}`,
|
|
815
|
+
forcedColors && `forced-colors: ${forcedColors}`
|
|
816
|
+
].filter(Boolean);
|
|
817
|
+
return {
|
|
818
|
+
content: [
|
|
819
|
+
{
|
|
820
|
+
type: "text",
|
|
821
|
+
text: `emulating ${applied.join(", ")} \u2014 re-render or navigate if the page only reads these at load`
|
|
822
|
+
}
|
|
823
|
+
]
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
);
|
|
586
827
|
}
|
|
587
828
|
function resolveOutputPath(outputDir, filename) {
|
|
588
829
|
const base = path.basename(filename);
|
|
@@ -612,8 +853,39 @@ function resolveRecordingFormat(filename) {
|
|
|
612
853
|
return null;
|
|
613
854
|
}
|
|
614
855
|
|
|
856
|
+
// src/sweep.ts
|
|
857
|
+
var TEXT_SWEEP_LIMIT = 100;
|
|
858
|
+
var HTML_SWEEP_LIMIT = 10;
|
|
859
|
+
var SWEEP_READ_TIMEOUT_MS = 2e3;
|
|
860
|
+
async function sweepRead(elements, read) {
|
|
861
|
+
const settled = await Promise.allSettled(elements.map((element) => read(element)));
|
|
862
|
+
return settled.map(
|
|
863
|
+
(result) => result.status === "fulfilled" ? { ok: true, value: result.value } : { ok: false }
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
function sweepBody(readings, render, separator = "\n") {
|
|
867
|
+
return readings.map(
|
|
868
|
+
(reading, index) => reading.ok ? `[${index}] ${render(reading.value)}` : `[${index}] (unreadable \u2014 the page replaced this element mid-sweep)`
|
|
869
|
+
).join(separator);
|
|
870
|
+
}
|
|
871
|
+
function sweepSummary(readings, total, selector) {
|
|
872
|
+
const shown = readings.length;
|
|
873
|
+
const unreadable = readings.filter((reading) => !reading.ok).length;
|
|
874
|
+
const head = shown >= total ? `\u2014 ${total} element${total === 1 ? "" : "s"} matched ${selector}` : `\u2014 showing the first ${shown} of ${total} elements matching ${selector}; raise \`limit\` to see more`;
|
|
875
|
+
if (unreadable === 0) return head;
|
|
876
|
+
return `${head}; ${unreadable} could not be read \u2014 re-run if you need ${unreadable === 1 ? "it" : "them"}`;
|
|
877
|
+
}
|
|
878
|
+
|
|
615
879
|
// src/tools/inspection.ts
|
|
616
880
|
var sessionArg = zod.z.string().optional().describe("Session ID to act on. Omit to use the default session.");
|
|
881
|
+
var allArg = zod.z.boolean().optional().describe(
|
|
882
|
+
"Read every element the selector matches instead of just the first. Use it to sweep a page \u2014 all image sources, all row labels, all link hrefs \u2014 in one call."
|
|
883
|
+
);
|
|
884
|
+
function matchLimitArg(fallback) {
|
|
885
|
+
return zod.z.number().int().positive().optional().describe(
|
|
886
|
+
`With \`all\`, the maximum number of matches to return (default ${fallback}). The total number found is always reported.`
|
|
887
|
+
);
|
|
888
|
+
}
|
|
617
889
|
function registerInspectionTools(server, ctx) {
|
|
618
890
|
server.registerTool(
|
|
619
891
|
"human_screenshot",
|
|
@@ -634,7 +906,11 @@ function registerInspectionTools(server, ctx) {
|
|
|
634
906
|
async ({ selector, fullPage, filename, session }) => {
|
|
635
907
|
const { human, page } = await ctx.sessions.get(session);
|
|
636
908
|
const buffer = selector ? await page.locator(selector).screenshot() : await human.screenshot({ fullPage: fullPage ?? false });
|
|
637
|
-
const
|
|
909
|
+
const scope = selector ? `element ${selector}` : fullPage ? "full page" : "viewport";
|
|
910
|
+
const content = [
|
|
911
|
+
{ type: "image", data: buffer.toString("base64"), mimeType: "image/png" },
|
|
912
|
+
{ type: "text", text: `captured ${scope} of ${page.url()}` }
|
|
913
|
+
];
|
|
638
914
|
if (filename) {
|
|
639
915
|
const path = resolveOutputPath(ctx.env.outputDir, filename);
|
|
640
916
|
await promises.writeFile(path, buffer);
|
|
@@ -676,15 +952,29 @@ function registerInspectionTools(server, ctx) {
|
|
|
676
952
|
"human_get_text",
|
|
677
953
|
{
|
|
678
954
|
title: "Get an element's text",
|
|
679
|
-
description: "Returns the
|
|
955
|
+
description: "Returns the innerText of the first element matching `selector` \u2014 a specific label, price, status, or message. Set `all` to read every match instead, e.g. every row's title in a table; a sweep reports each match in document order, including ones that are not currently visible.",
|
|
680
956
|
inputSchema: {
|
|
681
957
|
selector: zod.z.string().describe("Selector of the element to read."),
|
|
958
|
+
all: allArg,
|
|
959
|
+
limit: matchLimitArg(TEXT_SWEEP_LIMIT),
|
|
682
960
|
session: sessionArg
|
|
683
961
|
}
|
|
684
962
|
},
|
|
685
|
-
async ({ selector, session }) => {
|
|
963
|
+
async ({ selector, all, limit, session }) => {
|
|
686
964
|
const { page } = await ctx.sessions.get(session);
|
|
687
|
-
|
|
965
|
+
if (!all) {
|
|
966
|
+
const text2 = await page.locator(selector).first().innerText();
|
|
967
|
+
return { content: [{ type: "text", text: text2 }] };
|
|
968
|
+
}
|
|
969
|
+
const elements = await page.locator(selector).all();
|
|
970
|
+
const shown = elements.slice(0, limit ?? TEXT_SWEEP_LIMIT);
|
|
971
|
+
const readings = await sweepRead(
|
|
972
|
+
shown,
|
|
973
|
+
(element) => element.innerText({ timeout: SWEEP_READ_TIMEOUT_MS })
|
|
974
|
+
);
|
|
975
|
+
const text = elements.length === 0 ? `No elements matched ${selector}` : `${sweepBody(readings, (value) => value)}
|
|
976
|
+
|
|
977
|
+
${sweepSummary(readings, elements.length, selector)}`;
|
|
688
978
|
return { content: [{ type: "text", text }] };
|
|
689
979
|
}
|
|
690
980
|
);
|
|
@@ -692,17 +982,31 @@ function registerInspectionTools(server, ctx) {
|
|
|
692
982
|
"human_get_attribute",
|
|
693
983
|
{
|
|
694
984
|
title: "Get an element's attribute",
|
|
695
|
-
description: "Returns the value of an attribute on the first element matching `selector` (or reports it is absent). Handy for reading aria-label, data-*, href, value, disabled state, etc. \u2014 often how you confirm an icon-only button's purpose.",
|
|
985
|
+
description: "Returns the value of an attribute on the first element matching `selector` (or reports it is absent). Handy for reading aria-label, data-*, href, value, disabled state, etc. \u2014 often how you confirm an icon-only button's purpose. Set `all` to collect the attribute across every match, which is the compact way to extract every image source or link target on a page.",
|
|
696
986
|
inputSchema: {
|
|
697
987
|
selector: zod.z.string().describe("Selector of the element."),
|
|
698
988
|
attribute: zod.z.string().describe('Attribute name, e.g. "aria-label", "href", "data-state".'),
|
|
989
|
+
all: allArg,
|
|
990
|
+
limit: matchLimitArg(TEXT_SWEEP_LIMIT),
|
|
699
991
|
session: sessionArg
|
|
700
992
|
}
|
|
701
993
|
},
|
|
702
|
-
async ({ selector, attribute, session }) => {
|
|
994
|
+
async ({ selector, attribute, all, limit, session }) => {
|
|
703
995
|
const { page } = await ctx.sessions.get(session);
|
|
704
|
-
|
|
705
|
-
|
|
996
|
+
if (!all) {
|
|
997
|
+
const value = await page.locator(selector).first().getAttribute(attribute);
|
|
998
|
+
const text2 = value === null ? `${selector} has no attribute "${attribute}"` : `${attribute}="${value}"`;
|
|
999
|
+
return { content: [{ type: "text", text: text2 }] };
|
|
1000
|
+
}
|
|
1001
|
+
const elements = await page.locator(selector).all();
|
|
1002
|
+
const shown = elements.slice(0, limit ?? TEXT_SWEEP_LIMIT);
|
|
1003
|
+
const readings = await sweepRead(
|
|
1004
|
+
shown,
|
|
1005
|
+
(element) => element.getAttribute(attribute, { timeout: SWEEP_READ_TIMEOUT_MS })
|
|
1006
|
+
);
|
|
1007
|
+
const text = elements.length === 0 ? `No elements matched ${selector}` : `${sweepBody(readings, (value) => value ?? "(absent)")}
|
|
1008
|
+
|
|
1009
|
+
${sweepSummary(readings, elements.length, selector)}`;
|
|
706
1010
|
return { content: [{ type: "text", text }] };
|
|
707
1011
|
}
|
|
708
1012
|
);
|
|
@@ -710,16 +1014,109 @@ function registerInspectionTools(server, ctx) {
|
|
|
710
1014
|
"human_get_html",
|
|
711
1015
|
{
|
|
712
1016
|
title: "Get an element's HTML",
|
|
713
|
-
description: "Returns the outerHTML of the first element matching `selector` \u2014 the element plus its children, including its own attributes (class, aria-label, etc.). The go-to tool for discovering the real selector of a control with no obvious text. Target a specific region; full-page HTML is large.",
|
|
1017
|
+
description: "Returns the outerHTML of the first element matching `selector` \u2014 the element plus its children, including its own attributes (class, aria-label, etc.). The go-to tool for discovering the real selector of a control with no obvious text. Target a specific region; full-page HTML is large. Set `all` to dump every match, but prefer human_get_attribute with `all` when you only need one attribute from each \u2014 it returns the same information for a fraction of the tokens.",
|
|
714
1018
|
inputSchema: {
|
|
715
1019
|
selector: zod.z.string().describe("Selector of the region to dump. Target narrowly."),
|
|
1020
|
+
all: allArg,
|
|
1021
|
+
limit: matchLimitArg(HTML_SWEEP_LIMIT),
|
|
716
1022
|
session: sessionArg
|
|
717
1023
|
}
|
|
718
1024
|
},
|
|
719
|
-
async ({ selector, session }) => {
|
|
1025
|
+
async ({ selector, all, limit, session }) => {
|
|
720
1026
|
const { page } = await ctx.sessions.get(session);
|
|
721
|
-
|
|
722
|
-
|
|
1027
|
+
if (!all) {
|
|
1028
|
+
const html = await page.locator(selector).first().evaluate((el) => el.outerHTML);
|
|
1029
|
+
return { content: [{ type: "text", text: html }] };
|
|
1030
|
+
}
|
|
1031
|
+
const elements = await page.locator(selector).all();
|
|
1032
|
+
const shown = elements.slice(0, limit ?? HTML_SWEEP_LIMIT);
|
|
1033
|
+
const readings = await sweepRead(
|
|
1034
|
+
shown,
|
|
1035
|
+
(element) => element.evaluate((el) => el.outerHTML, void 0, { timeout: SWEEP_READ_TIMEOUT_MS })
|
|
1036
|
+
);
|
|
1037
|
+
const text = elements.length === 0 ? `No elements matched ${selector}` : `${sweepBody(readings, (value) => value, "\n\n")}
|
|
1038
|
+
|
|
1039
|
+
${sweepSummary(readings, elements.length, selector)}`;
|
|
1040
|
+
return { content: [{ type: "text", text }] };
|
|
1041
|
+
}
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
var sessionArg2 = zod.z.string().optional().describe("Session ID to read from. Omit to use the default session.");
|
|
1045
|
+
var patternArg = zod.z.string().optional().describe(
|
|
1046
|
+
'Case-insensitive regular expression to filter by. Omit to see everything. Use it to cut noise on chatty pages, e.g. "cors|refused" or "\\\\.png$".'
|
|
1047
|
+
);
|
|
1048
|
+
var DEFAULT_LIMIT = 50;
|
|
1049
|
+
var limitArg = zod.z.number().int().positive().optional().describe(
|
|
1050
|
+
`Maximum entries to return, most recent first-in-list-last (default ${DEFAULT_LIMIT}). The count of everything that matched is always reported, so you can tell when you are seeing a slice.`
|
|
1051
|
+
);
|
|
1052
|
+
var clearArg = zod.z.boolean().optional().describe(
|
|
1053
|
+
"Empty the WHOLE buffer after this read \u2014 not only the entries returned, and not only the ones that matched your filter. Use it for a clean slate before an action, so the next call shows just what that action caused. How many entries it discarded is reported back."
|
|
1054
|
+
);
|
|
1055
|
+
function withClearNote(text, discarded) {
|
|
1056
|
+
if (discarded === 0) return `${text}
|
|
1057
|
+
\u2014 buffer was already empty`;
|
|
1058
|
+
return `${text}
|
|
1059
|
+
\u2014 buffer cleared, ${discarded} entr${discarded === 1 ? "y" : "ies"} discarded`;
|
|
1060
|
+
}
|
|
1061
|
+
function registerObservabilityTools(server, { sessions }) {
|
|
1062
|
+
server.registerTool(
|
|
1063
|
+
"human_console_messages",
|
|
1064
|
+
{
|
|
1065
|
+
title: "Read the browser console",
|
|
1066
|
+
description: "Returns console output and uncaught page errors captured for this session, newest last. Capture starts when the session is created, so this covers everything since \u2014 including errors thrown during the first page load. The go-to tool when something looks wrong on the page but the screenshot doesn't explain why: CORS rejections, failed asset loads, and thrown exceptions all surface here and nowhere else.",
|
|
1067
|
+
inputSchema: {
|
|
1068
|
+
pattern: patternArg,
|
|
1069
|
+
onlyErrors: zod.z.boolean().optional().describe(
|
|
1070
|
+
"Keep only console.error output and uncaught page errors, dropping log/info/warn/debug."
|
|
1071
|
+
),
|
|
1072
|
+
limit: limitArg,
|
|
1073
|
+
clear: clearArg,
|
|
1074
|
+
session: sessionArg2
|
|
1075
|
+
}
|
|
1076
|
+
},
|
|
1077
|
+
async ({ pattern, onlyErrors, limit, clear, session }) => {
|
|
1078
|
+
const { observers } = await sessions.get(session);
|
|
1079
|
+
const result = queryConsole(observers.console, {
|
|
1080
|
+
pattern,
|
|
1081
|
+
onlyErrors,
|
|
1082
|
+
limit: limit ?? DEFAULT_LIMIT
|
|
1083
|
+
});
|
|
1084
|
+
const formatted = formatConsole(result);
|
|
1085
|
+
const discarded = observers.console.size;
|
|
1086
|
+
if (clear) observers.console.clear();
|
|
1087
|
+
const text = clear ? withClearNote(formatted, discarded) : formatted;
|
|
1088
|
+
return { content: [{ type: "text", text }] };
|
|
1089
|
+
}
|
|
1090
|
+
);
|
|
1091
|
+
server.registerTool(
|
|
1092
|
+
"human_network_requests",
|
|
1093
|
+
{
|
|
1094
|
+
title: "Inspect network activity",
|
|
1095
|
+
description: 'Returns network responses captured for this session, newest last, with method, URL, HTTP status and round-trip time. Requests that never got a response (DNS failure, blocked by CORS, aborted) are included with the browser\'s failure reason instead of a status. Use `onlyFailures` to answer "what broke?" in one call \u2014 it covers both 4xx/5xx responses and requests that never completed.',
|
|
1096
|
+
inputSchema: {
|
|
1097
|
+
pattern: patternArg,
|
|
1098
|
+
onlyFailures: zod.z.boolean().optional().describe(
|
|
1099
|
+
"Keep only 4xx/5xx responses and requests that failed before responding. The fastest way to find a broken asset or API call."
|
|
1100
|
+
),
|
|
1101
|
+
status: zod.z.number().int().optional().describe("Keep only responses with this exact HTTP status, e.g. 404 or 500."),
|
|
1102
|
+
limit: limitArg,
|
|
1103
|
+
clear: clearArg,
|
|
1104
|
+
session: sessionArg2
|
|
1105
|
+
}
|
|
1106
|
+
},
|
|
1107
|
+
async ({ pattern, onlyFailures, status, limit, clear, session }) => {
|
|
1108
|
+
const { observers } = await sessions.get(session);
|
|
1109
|
+
const result = queryNetwork(observers.network, {
|
|
1110
|
+
pattern,
|
|
1111
|
+
onlyFailures,
|
|
1112
|
+
status,
|
|
1113
|
+
limit: limit ?? DEFAULT_LIMIT
|
|
1114
|
+
});
|
|
1115
|
+
const formatted = formatNetwork(result);
|
|
1116
|
+
const discarded = observers.network.size;
|
|
1117
|
+
if (clear) observers.network.clear();
|
|
1118
|
+
const text = clear ? withClearNote(formatted, discarded) : formatted;
|
|
1119
|
+
return { content: [{ type: "text", text }] };
|
|
723
1120
|
}
|
|
724
1121
|
);
|
|
725
1122
|
}
|
|
@@ -747,7 +1144,7 @@ function resolveTarget(input) {
|
|
|
747
1144
|
}
|
|
748
1145
|
|
|
749
1146
|
// src/tools/primitives.ts
|
|
750
|
-
var
|
|
1147
|
+
var sessionArg3 = zod.z.string().optional().describe(
|
|
751
1148
|
"Session ID to act on. Omit to use the default session (created lazily on first call). Use human_create_session for parallel browsers."
|
|
752
1149
|
);
|
|
753
1150
|
function registerPrimitiveTools(server, { sessions, env }) {
|
|
@@ -758,7 +1155,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
758
1155
|
description: `Navigates the session's page to a URL. Plugins observe a "goto" action. Equivalent to a user typing a URL in the address bar.`,
|
|
759
1156
|
inputSchema: {
|
|
760
1157
|
url: zod.z.string().url().describe("Absolute URL to navigate to."),
|
|
761
|
-
session:
|
|
1158
|
+
session: sessionArg3
|
|
762
1159
|
}
|
|
763
1160
|
},
|
|
764
1161
|
async ({ url, session }) => {
|
|
@@ -772,7 +1169,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
772
1169
|
{
|
|
773
1170
|
title: "Click (humanized)",
|
|
774
1171
|
description: "Moves the cursor to the target along a humanized Bezier path and clicks. Target is a selector OR x/y coordinates \u2014 use coordinates for icon-only buttons or anything with no clean selector that you can see in a screenshot.",
|
|
775
|
-
inputSchema: { ...targetFields, session:
|
|
1172
|
+
inputSchema: { ...targetFields, session: sessionArg3 }
|
|
776
1173
|
},
|
|
777
1174
|
async ({ selector, x, y, session }) => {
|
|
778
1175
|
const { human } = await sessions.get(session);
|
|
@@ -786,7 +1183,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
786
1183
|
{
|
|
787
1184
|
title: "Right-click (humanized)",
|
|
788
1185
|
description: "Right-clicks the target to open a context menu. Same motion as human_click; only the dispatched button differs. Target is a selector OR x/y coordinates.",
|
|
789
|
-
inputSchema: { ...targetFields, session:
|
|
1186
|
+
inputSchema: { ...targetFields, session: sessionArg3 }
|
|
790
1187
|
},
|
|
791
1188
|
async ({ selector, x, y, session }) => {
|
|
792
1189
|
const { human } = await sessions.get(session);
|
|
@@ -802,7 +1199,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
802
1199
|
{
|
|
803
1200
|
title: "Double-click (humanized)",
|
|
804
1201
|
description: "Double-clicks the target \u2014 same humanized motion as human_click, but two presses within the OS double-click window. Use for things that open/activate on double-click (list rows, file items, editable cells). Target is a selector OR x/y coordinates.",
|
|
805
|
-
inputSchema: { ...targetFields, session:
|
|
1202
|
+
inputSchema: { ...targetFields, session: sessionArg3 }
|
|
806
1203
|
},
|
|
807
1204
|
async ({ selector, x, y, session }) => {
|
|
808
1205
|
const { human } = await sessions.get(session);
|
|
@@ -820,7 +1217,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
820
1217
|
description: "Moves the cursor to an element and settles on it (no click), letting hover-triggered UI fire \u2014 tooltips, dropdowns. Element-bound only; for positioning the cursor at coordinates without an element, use human_move.",
|
|
821
1218
|
inputSchema: {
|
|
822
1219
|
selector: zod.z.string().describe("Selector of the element to hover."),
|
|
823
|
-
session:
|
|
1220
|
+
session: sessionArg3
|
|
824
1221
|
}
|
|
825
1222
|
},
|
|
826
1223
|
async ({ selector, session }) => {
|
|
@@ -834,7 +1231,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
834
1231
|
{
|
|
835
1232
|
title: "Move the cursor (humanized)",
|
|
836
1233
|
description: "Moves the cursor to a target along a Bezier path with no click and no settle dwell \u2014 pure positioning. Useful before a keyboard action, for canvas work, or cinematic beats. Target is a selector OR x/y coordinates.",
|
|
837
|
-
inputSchema: { ...targetFields, session:
|
|
1234
|
+
inputSchema: { ...targetFields, session: sessionArg3 }
|
|
838
1235
|
},
|
|
839
1236
|
async ({ selector, x, y, session }) => {
|
|
840
1237
|
const { human } = await sessions.get(session);
|
|
@@ -855,7 +1252,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
855
1252
|
toSelector: zod.z.string().optional().describe("Destination selector. Provide this OR toX/toY."),
|
|
856
1253
|
toX: zod.z.number().optional().describe("Destination X coordinate. Requires toY."),
|
|
857
1254
|
toY: zod.z.number().optional().describe("Destination Y coordinate. Requires toX."),
|
|
858
|
-
session:
|
|
1255
|
+
session: sessionArg3
|
|
859
1256
|
}
|
|
860
1257
|
},
|
|
861
1258
|
async ({ fromSelector, fromX, fromY, toSelector, toX, toY, session }) => {
|
|
@@ -881,7 +1278,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
881
1278
|
inputSchema: {
|
|
882
1279
|
selector: zod.z.string().describe("Selector of the input/textarea/contenteditable."),
|
|
883
1280
|
value: zod.z.string().describe("Text to type. May contain newlines."),
|
|
884
|
-
session:
|
|
1281
|
+
session: sessionArg3
|
|
885
1282
|
}
|
|
886
1283
|
},
|
|
887
1284
|
async ({ selector, value, session }) => {
|
|
@@ -898,7 +1295,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
898
1295
|
inputSchema: {
|
|
899
1296
|
selector: zod.z.string().describe("Selector of the field to paste into."),
|
|
900
1297
|
value: zod.z.string().describe("Text to insert."),
|
|
901
|
-
session:
|
|
1298
|
+
session: sessionArg3
|
|
902
1299
|
}
|
|
903
1300
|
},
|
|
904
1301
|
async ({ selector, value, session }) => {
|
|
@@ -914,7 +1311,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
914
1311
|
description: "Clears a text field (input/textarea/contenteditable) with a real keyboard gesture \u2014 click to focus, select-all, then delete \u2014 firing the input events the page expects. Use before human_type when you need to replace an existing value rather than append to it.",
|
|
915
1312
|
inputSchema: {
|
|
916
1313
|
selector: zod.z.string().describe("Selector of the field to clear."),
|
|
917
|
-
session:
|
|
1314
|
+
session: sessionArg3
|
|
918
1315
|
}
|
|
919
1316
|
},
|
|
920
1317
|
async ({ selector, session }) => {
|
|
@@ -931,7 +1328,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
931
1328
|
inputSchema: {
|
|
932
1329
|
selector: zod.z.string().describe("Selector of the element whose text to select."),
|
|
933
1330
|
text: zod.z.string().optional().describe("Optional substring to select instead of the element\u2019s whole text."),
|
|
934
|
-
session:
|
|
1331
|
+
session: sessionArg3
|
|
935
1332
|
}
|
|
936
1333
|
},
|
|
937
1334
|
async ({ selector, text, session }) => {
|
|
@@ -948,7 +1345,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
948
1345
|
description: "Ticks a checkbox or radio \u2014 moves the cursor to it and clicks, but only if it is not already checked (a real user does not re-click a ticked box). Verifies the resulting state. Pass the checkbox/radio input itself (or a [role=checkbox]) \u2014 not a wrapping <label> \u2014 so the current state can be read and the click stays idempotent.",
|
|
949
1346
|
inputSchema: {
|
|
950
1347
|
selector: zod.z.string().describe("Selector of the checkbox/radio input."),
|
|
951
|
-
session:
|
|
1348
|
+
session: sessionArg3
|
|
952
1349
|
}
|
|
953
1350
|
},
|
|
954
1351
|
async ({ selector, session }) => {
|
|
@@ -964,7 +1361,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
964
1361
|
description: "Unticks a checkbox \u2014 humanized click only if currently checked. Radios cannot be unchecked by clicking (select a different option instead). Pass the checkbox input itself (or a [role=checkbox]) \u2014 not a wrapping <label> \u2014 so its state can be read and the click stays idempotent.",
|
|
965
1362
|
inputSchema: {
|
|
966
1363
|
selector: zod.z.string().describe("Selector of the checkbox input."),
|
|
967
|
-
session:
|
|
1364
|
+
session: sessionArg3
|
|
968
1365
|
}
|
|
969
1366
|
},
|
|
970
1367
|
async ({ selector, session }) => {
|
|
@@ -981,7 +1378,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
981
1378
|
inputSchema: {
|
|
982
1379
|
selector: zod.z.string().describe("Selector of the <select> element."),
|
|
983
1380
|
values: zod.z.union([zod.z.string(), zod.z.array(zod.z.string())]).describe("Option value, or array of values for a multi-select."),
|
|
984
|
-
session:
|
|
1381
|
+
session: sessionArg3
|
|
985
1382
|
}
|
|
986
1383
|
},
|
|
987
1384
|
async ({ selector, values, session }) => {
|
|
@@ -1000,7 +1397,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
1000
1397
|
inputSchema: {
|
|
1001
1398
|
selector: zod.z.string().describe("Selector of the file input."),
|
|
1002
1399
|
files: zod.z.union([zod.z.string(), zod.z.array(zod.z.string())]).describe("Filename(s) inside HUMANJS_UPLOAD_DIR \u2014 a basename only, no path components."),
|
|
1003
|
-
session:
|
|
1400
|
+
session: sessionArg3
|
|
1004
1401
|
}
|
|
1005
1402
|
},
|
|
1006
1403
|
async ({ selector, files, session }) => {
|
|
@@ -1020,7 +1417,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
1020
1417
|
description: 'Presses a single key (Enter, Tab, Escape, ArrowDown, \u2026) or a chord (Mod+S, Cmd+Shift+P, Ctrl+C). "Mod" maps to Meta on Mac and Control elsewhere. Dispatches against focus \u2014 does not move the cursor; compose with human_click/human_move when you need both.',
|
|
1021
1418
|
inputSchema: {
|
|
1022
1419
|
key: zod.z.string().describe('Key or chord, e.g. "Enter", "Tab", "Mod+S", "Ctrl+Shift+K".'),
|
|
1023
|
-
session:
|
|
1420
|
+
session: sessionArg3
|
|
1024
1421
|
}
|
|
1025
1422
|
},
|
|
1026
1423
|
async ({ key, session }) => {
|
|
@@ -1042,7 +1439,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
1042
1439
|
to: zod.z.number().optional().describe("Absolute scroll position on the chosen axis."),
|
|
1043
1440
|
axis: zod.z.enum(["x", "y"]).optional().describe('Axis to scroll. Defaults to "y".'),
|
|
1044
1441
|
within: zod.z.string().optional().describe("Selector of a scrollable container to scope the scroll to."),
|
|
1045
|
-
session:
|
|
1442
|
+
session: sessionArg3
|
|
1046
1443
|
}
|
|
1047
1444
|
},
|
|
1048
1445
|
async ({ target, by, to, axis, within, session }) => {
|
|
@@ -1065,7 +1462,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
1065
1462
|
selector: zod.z.string().optional().describe("Selector of the text to read. Provide this OR text."),
|
|
1066
1463
|
text: zod.z.string().optional().describe('Literal text to "read" (no DOM lookup). Provide this OR selector.'),
|
|
1067
1464
|
kind: zod.z.enum(["prose", "code", "scan"]).optional().describe('Reading style. Auto-detected as "code" for <pre>/<code> when omitted.'),
|
|
1068
|
-
session:
|
|
1465
|
+
session: sessionArg3
|
|
1069
1466
|
}
|
|
1070
1467
|
},
|
|
1071
1468
|
async ({ selector, text, kind, session }) => {
|
|
@@ -1229,6 +1626,12 @@ Export as a test: human_stop_recording picks format by extension. A .spec.ts (or
|
|
|
1229
1626
|
|
|
1230
1627
|
Captured input + passwords: typed/pasted text IS recorded into the timeline and code exports, so generated scripts/tests are runnable \u2014 EXCEPT password fields, which are always masked (emitted as an empty string with a "fill in" comment). This is intentional, not a bug; don't work around it by hand-editing the secret back in. If the user explicitly wants the flow to log in, edit the exported file to read the credential from an env var (e.g. process.env.APP_PASSWORD) and tell them to set it \u2014 never hardcode a real password into a file that may be committed.
|
|
1231
1628
|
|
|
1629
|
+
When something looks wrong and the screenshot doesn't explain it, read human_console_messages (onlyErrors: true) and human_network_requests (onlyFailures: true) before guessing. A CORS rejection, a 404 on an image, or an uncaught TypeError appears in neither the screenshot nor the page text \u2014 those two tools are the only place it shows up. Capture is always on, so you can act first and check afterwards. Do NOT reach for curl to debug a page: a request made outside the browser carries none of its origin, cookies, or headers, so the failure you are chasing won't reproduce.
|
|
1630
|
+
|
|
1631
|
+
Accessibility and theming: human_emulate_media sets prefers-reduced-motion, prefers-color-scheme and forced-colors. Use reducedMotion: 'reduce' to verify a reduced-motion path \u2014 it is otherwise untestable without changing OS settings.
|
|
1632
|
+
|
|
1633
|
+
Reading many elements at once: human_get_text, human_get_attribute and human_get_html take all: true to return every match instead of the first. For "every image source" or "every link target", human_get_attribute with all is far cheaper than dumping HTML.
|
|
1634
|
+
|
|
1232
1635
|
Dynamic UI: prefer specific selectors (role, aria-label) over text \u2014 the same visible text often matches several cards before a filter, or the wrong one after. If a click reports multiple matches, narrow the selector.
|
|
1233
1636
|
|
|
1234
1637
|
Browser state: by default each run is a fresh, signed-out browser. If a flow needs a login, tell the user to enable persistence (human_enable_persistence or HUMANJS_PERSIST) or CDP attach \u2014 see human_browser_info.`;
|
|
@@ -1242,6 +1645,7 @@ async function main() {
|
|
|
1242
1645
|
);
|
|
1243
1646
|
registerPrimitiveTools(server, ctx);
|
|
1244
1647
|
registerInspectionTools(server, ctx);
|
|
1648
|
+
registerObservabilityTools(server, ctx);
|
|
1245
1649
|
registerRecordingTools(server, ctx);
|
|
1246
1650
|
registerSessionTools(server, ctx);
|
|
1247
1651
|
registerConfigTools(server, ctx);
|