@humanjs/mcp 0.4.2 → 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 +3 -3
package/dist/index.js
CHANGED
|
@@ -71,6 +71,197 @@ function parseViewport(raw) {
|
|
|
71
71
|
}
|
|
72
72
|
return { width: Number(match[1]), height: Number(match[2]) };
|
|
73
73
|
}
|
|
74
|
+
|
|
75
|
+
// src/observability.ts
|
|
76
|
+
var RingBuffer = class {
|
|
77
|
+
constructor(limit) {
|
|
78
|
+
this.limit = limit;
|
|
79
|
+
if (limit < 1) throw new Error(`RingBuffer limit must be >= 1, got ${limit}`);
|
|
80
|
+
}
|
|
81
|
+
limit;
|
|
82
|
+
items = [];
|
|
83
|
+
droppedCount = 0;
|
|
84
|
+
push(item) {
|
|
85
|
+
this.items.push(item);
|
|
86
|
+
if (this.items.length > this.limit) {
|
|
87
|
+
this.items.shift();
|
|
88
|
+
this.droppedCount += 1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
toArray() {
|
|
92
|
+
return this.items;
|
|
93
|
+
}
|
|
94
|
+
/** Entries evicted because the buffer was full. */
|
|
95
|
+
get dropped() {
|
|
96
|
+
return this.droppedCount;
|
|
97
|
+
}
|
|
98
|
+
get size() {
|
|
99
|
+
return this.items.length;
|
|
100
|
+
}
|
|
101
|
+
clear() {
|
|
102
|
+
this.items = [];
|
|
103
|
+
this.droppedCount = 0;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
var CONSOLE_BUFFER_LIMIT = 500;
|
|
107
|
+
var NETWORK_BUFFER_LIMIT = 500;
|
|
108
|
+
var ERROR_LEVELS = /* @__PURE__ */ new Set(["error", "pageerror"]);
|
|
109
|
+
var MAX_PATTERN_LENGTH = 200;
|
|
110
|
+
function compilePattern(pattern) {
|
|
111
|
+
if (pattern.length > MAX_PATTERN_LENGTH) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`\`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.`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
return new RegExp(pattern, "i");
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Invalid \`pattern\` regular expression: ${detail}. Pass a valid JS regex (it is matched case-insensitively), or omit \`pattern\` to see everything.`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function queryConsole(buffer, query) {
|
|
126
|
+
const all = buffer.toArray();
|
|
127
|
+
const regex = query.pattern ? compilePattern(query.pattern) : null;
|
|
128
|
+
const matches = all.filter((entry) => {
|
|
129
|
+
if (query.onlyErrors && !ERROR_LEVELS.has(entry.level)) return false;
|
|
130
|
+
if (regex && !regex.test(entry.text)) return false;
|
|
131
|
+
return true;
|
|
132
|
+
});
|
|
133
|
+
return {
|
|
134
|
+
entries: takeLast(matches, query.limit),
|
|
135
|
+
matched: matches.length,
|
|
136
|
+
total: all.length,
|
|
137
|
+
dropped: buffer.dropped
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function queryNetwork(buffer, query) {
|
|
141
|
+
const all = buffer.toArray();
|
|
142
|
+
const regex = query.pattern ? compilePattern(query.pattern) : null;
|
|
143
|
+
const matches = all.filter((entry) => {
|
|
144
|
+
if (query.onlyFailures && !isFailure(entry)) return false;
|
|
145
|
+
if (query.status !== void 0 && entry.status !== query.status) return false;
|
|
146
|
+
if (regex && !regex.test(entry.url)) return false;
|
|
147
|
+
return true;
|
|
148
|
+
});
|
|
149
|
+
return {
|
|
150
|
+
entries: takeLast(matches, query.limit),
|
|
151
|
+
matched: matches.length,
|
|
152
|
+
total: all.length,
|
|
153
|
+
dropped: buffer.dropped
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function isFailure(entry) {
|
|
157
|
+
if (entry.failure !== null) return true;
|
|
158
|
+
return entry.status !== null && entry.status >= 400;
|
|
159
|
+
}
|
|
160
|
+
function takeLast(items, limit) {
|
|
161
|
+
if (limit === void 0 || items.length <= limit) return items;
|
|
162
|
+
return items.slice(items.length - limit);
|
|
163
|
+
}
|
|
164
|
+
function formatConsole(result) {
|
|
165
|
+
if (result.entries.length === 0) return emptyMessage("console messages", result);
|
|
166
|
+
const lines = result.entries.map((entry) => {
|
|
167
|
+
const where = entry.source ? ` (${entry.source})` : "";
|
|
168
|
+
return `[${entry.level}] ${entry.text}${where}`;
|
|
169
|
+
});
|
|
170
|
+
return [...lines, "", summary("message", result)].join("\n");
|
|
171
|
+
}
|
|
172
|
+
function formatNetwork(result) {
|
|
173
|
+
if (result.entries.length === 0) return emptyMessage("network requests", result);
|
|
174
|
+
const lines = result.entries.map((entry) => {
|
|
175
|
+
const status = entry.failure !== null ? `FAILED (${entry.failure})` : String(entry.status);
|
|
176
|
+
const timing = entry.ms === null ? "" : ` ${entry.ms}ms`;
|
|
177
|
+
return `${status} ${entry.method} ${entry.url} [${entry.resourceType}]${timing}`;
|
|
178
|
+
});
|
|
179
|
+
return [...lines, "", summary("request", result)].join("\n");
|
|
180
|
+
}
|
|
181
|
+
function emptyMessage(noun, result) {
|
|
182
|
+
if (result.total === 0 && result.dropped === 0) {
|
|
183
|
+
return `No ${noun} captured yet for this session.`;
|
|
184
|
+
}
|
|
185
|
+
const dropped = result.dropped > 0 ? ` ${result.dropped} older ${noun} were dropped from the buffer, so this is not proof they never happened.` : "";
|
|
186
|
+
return `No ${noun} matched the filter (${result.total} in the buffer).${dropped}`;
|
|
187
|
+
}
|
|
188
|
+
function summary(noun, result) {
|
|
189
|
+
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"}`;
|
|
190
|
+
const dropped = result.dropped > 0 ? `; ${result.dropped} older dropped (buffer full)` : "";
|
|
191
|
+
return `\u2014 ${shown} of ${result.total} captured${dropped}`;
|
|
192
|
+
}
|
|
193
|
+
function attachObservers(page) {
|
|
194
|
+
const consoleBuffer = new RingBuffer(CONSOLE_BUFFER_LIMIT);
|
|
195
|
+
const networkBuffer = new RingBuffer(NETWORK_BUFFER_LIMIT);
|
|
196
|
+
const startedAt = /* @__PURE__ */ new WeakMap();
|
|
197
|
+
const onConsole = (message) => {
|
|
198
|
+
const location = message.location();
|
|
199
|
+
const source = location.url ? `${location.url}:${location.lineNumber}:${location.columnNumber}` : null;
|
|
200
|
+
consoleBuffer.push({
|
|
201
|
+
level: message.type(),
|
|
202
|
+
text: message.text(),
|
|
203
|
+
source,
|
|
204
|
+
at: Date.now()
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
const onPageError = (error) => {
|
|
208
|
+
consoleBuffer.push({
|
|
209
|
+
level: "pageerror",
|
|
210
|
+
text: error.stack ?? `${error.name}: ${error.message}`,
|
|
211
|
+
source: null,
|
|
212
|
+
at: Date.now()
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
const onRequest = (request) => {
|
|
216
|
+
startedAt.set(request, Date.now());
|
|
217
|
+
};
|
|
218
|
+
const onResponse = (response) => {
|
|
219
|
+
const request = response.request();
|
|
220
|
+
const start = startedAt.get(request);
|
|
221
|
+
networkBuffer.push({
|
|
222
|
+
method: request.method(),
|
|
223
|
+
url: response.url(),
|
|
224
|
+
status: response.status(),
|
|
225
|
+
failure: null,
|
|
226
|
+
resourceType: request.resourceType(),
|
|
227
|
+
at: Date.now(),
|
|
228
|
+
ms: start === void 0 ? null : Date.now() - start
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
const onRequestFailed = (request) => {
|
|
232
|
+
const start = startedAt.get(request);
|
|
233
|
+
networkBuffer.push({
|
|
234
|
+
method: request.method(),
|
|
235
|
+
url: request.url(),
|
|
236
|
+
status: null,
|
|
237
|
+
failure: request.failure()?.errorText ?? "request failed",
|
|
238
|
+
resourceType: request.resourceType(),
|
|
239
|
+
at: Date.now(),
|
|
240
|
+
ms: start === void 0 ? null : Date.now() - start
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
page.on("console", onConsole);
|
|
244
|
+
page.on("pageerror", onPageError);
|
|
245
|
+
page.on("request", onRequest);
|
|
246
|
+
page.on("response", onResponse);
|
|
247
|
+
page.on("requestfailed", onRequestFailed);
|
|
248
|
+
let detached = false;
|
|
249
|
+
return {
|
|
250
|
+
console: consoleBuffer,
|
|
251
|
+
network: networkBuffer,
|
|
252
|
+
detach: () => {
|
|
253
|
+
if (detached) return;
|
|
254
|
+
detached = true;
|
|
255
|
+
page.off("console", onConsole);
|
|
256
|
+
page.off("pageerror", onPageError);
|
|
257
|
+
page.off("request", onRequest);
|
|
258
|
+
page.off("response", onResponse);
|
|
259
|
+
page.off("requestfailed", onRequestFailed);
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// src/session.ts
|
|
74
265
|
var DEFAULT_SESSION_ID = "default";
|
|
75
266
|
var SessionManager = class {
|
|
76
267
|
/** Backing browser for `ephemeral` and `cdp` modes. */
|
|
@@ -130,6 +321,7 @@ var SessionManager = class {
|
|
|
130
321
|
const personality = options.personality ?? this.env.personality;
|
|
131
322
|
const speed = options.speed ?? this.env.speed;
|
|
132
323
|
const human = await createHuman(page, { personality, speed });
|
|
324
|
+
const observers = attachObservers(page);
|
|
133
325
|
const session = {
|
|
134
326
|
id,
|
|
135
327
|
context,
|
|
@@ -139,6 +331,7 @@ var SessionManager = class {
|
|
|
139
331
|
speed,
|
|
140
332
|
mode: config.mode,
|
|
141
333
|
recording: null,
|
|
334
|
+
observers,
|
|
142
335
|
createdAt: Date.now()
|
|
143
336
|
};
|
|
144
337
|
this.sessions.set(id, session);
|
|
@@ -260,6 +453,7 @@ var SessionManager = class {
|
|
|
260
453
|
async close(id) {
|
|
261
454
|
const session = this.sessions.get(id);
|
|
262
455
|
if (!session) return;
|
|
456
|
+
session.observers.detach();
|
|
263
457
|
if (session.recording) {
|
|
264
458
|
const rec = session.recording;
|
|
265
459
|
session.recording = null;
|
|
@@ -517,6 +711,10 @@ function registerBrowserTools(server, { sessions }) {
|
|
|
517
711
|
);
|
|
518
712
|
}
|
|
519
713
|
var preset = z.enum(["careful", "fast", "distracted", "precise"]);
|
|
714
|
+
function toMediaValue(value) {
|
|
715
|
+
if (value === void 0) return void 0;
|
|
716
|
+
return value === "system" ? null : value;
|
|
717
|
+
}
|
|
520
718
|
function registerConfigTools(server, { sessions }) {
|
|
521
719
|
server.registerTool(
|
|
522
720
|
"human_set_personality",
|
|
@@ -580,6 +778,49 @@ function registerConfigTools(server, { sessions }) {
|
|
|
580
778
|
return { content: [{ type: "text", text: `viewport set to ${width}\xD7${height}` }] };
|
|
581
779
|
}
|
|
582
780
|
);
|
|
781
|
+
server.registerTool(
|
|
782
|
+
"human_emulate_media",
|
|
783
|
+
{
|
|
784
|
+
title: "Emulate media features",
|
|
785
|
+
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.",
|
|
786
|
+
inputSchema: {
|
|
787
|
+
reducedMotion: z.enum(["reduce", "no-preference", "system"]).optional().describe(
|
|
788
|
+
"Emulate prefers-reduced-motion. Use 'reduce' to verify the reduced-motion path renders and stays usable."
|
|
789
|
+
),
|
|
790
|
+
colorScheme: z.enum(["light", "dark", "no-preference", "system"]).optional().describe("Emulate prefers-color-scheme, to check the light and dark themes."),
|
|
791
|
+
forcedColors: z.enum(["active", "none", "system"]).optional().describe(
|
|
792
|
+
"Emulate forced-colors (Windows High Contrast). 'active' reveals content that disappears when the author's colors are overridden."
|
|
793
|
+
),
|
|
794
|
+
session: z.string().optional().describe("Session ID. Omit for the default session.")
|
|
795
|
+
}
|
|
796
|
+
},
|
|
797
|
+
async ({ reducedMotion, colorScheme, forcedColors, session }) => {
|
|
798
|
+
if (reducedMotion === void 0 && colorScheme === void 0 && forcedColors === void 0) {
|
|
799
|
+
throw new Error(
|
|
800
|
+
'Pass at least one of `reducedMotion`, `colorScheme` or `forcedColors`. Use "system" to stop emulating one.'
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
const { human } = await sessions.get(session);
|
|
804
|
+
await human.emulateMedia({
|
|
805
|
+
reducedMotion: toMediaValue(reducedMotion),
|
|
806
|
+
colorScheme: toMediaValue(colorScheme),
|
|
807
|
+
forcedColors: toMediaValue(forcedColors)
|
|
808
|
+
});
|
|
809
|
+
const applied = [
|
|
810
|
+
reducedMotion && `prefers-reduced-motion: ${reducedMotion}`,
|
|
811
|
+
colorScheme && `prefers-color-scheme: ${colorScheme}`,
|
|
812
|
+
forcedColors && `forced-colors: ${forcedColors}`
|
|
813
|
+
].filter(Boolean);
|
|
814
|
+
return {
|
|
815
|
+
content: [
|
|
816
|
+
{
|
|
817
|
+
type: "text",
|
|
818
|
+
text: `emulating ${applied.join(", ")} \u2014 re-render or navigate if the page only reads these at load`
|
|
819
|
+
}
|
|
820
|
+
]
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
);
|
|
583
824
|
}
|
|
584
825
|
function resolveOutputPath(outputDir, filename) {
|
|
585
826
|
const base = basename(filename);
|
|
@@ -609,8 +850,39 @@ function resolveRecordingFormat(filename) {
|
|
|
609
850
|
return null;
|
|
610
851
|
}
|
|
611
852
|
|
|
853
|
+
// src/sweep.ts
|
|
854
|
+
var TEXT_SWEEP_LIMIT = 100;
|
|
855
|
+
var HTML_SWEEP_LIMIT = 10;
|
|
856
|
+
var SWEEP_READ_TIMEOUT_MS = 2e3;
|
|
857
|
+
async function sweepRead(elements, read) {
|
|
858
|
+
const settled = await Promise.allSettled(elements.map((element) => read(element)));
|
|
859
|
+
return settled.map(
|
|
860
|
+
(result) => result.status === "fulfilled" ? { ok: true, value: result.value } : { ok: false }
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
function sweepBody(readings, render, separator = "\n") {
|
|
864
|
+
return readings.map(
|
|
865
|
+
(reading, index) => reading.ok ? `[${index}] ${render(reading.value)}` : `[${index}] (unreadable \u2014 the page replaced this element mid-sweep)`
|
|
866
|
+
).join(separator);
|
|
867
|
+
}
|
|
868
|
+
function sweepSummary(readings, total, selector) {
|
|
869
|
+
const shown = readings.length;
|
|
870
|
+
const unreadable = readings.filter((reading) => !reading.ok).length;
|
|
871
|
+
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`;
|
|
872
|
+
if (unreadable === 0) return head;
|
|
873
|
+
return `${head}; ${unreadable} could not be read \u2014 re-run if you need ${unreadable === 1 ? "it" : "them"}`;
|
|
874
|
+
}
|
|
875
|
+
|
|
612
876
|
// src/tools/inspection.ts
|
|
613
877
|
var sessionArg = z.string().optional().describe("Session ID to act on. Omit to use the default session.");
|
|
878
|
+
var allArg = z.boolean().optional().describe(
|
|
879
|
+
"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."
|
|
880
|
+
);
|
|
881
|
+
function matchLimitArg(fallback) {
|
|
882
|
+
return z.number().int().positive().optional().describe(
|
|
883
|
+
`With \`all\`, the maximum number of matches to return (default ${fallback}). The total number found is always reported.`
|
|
884
|
+
);
|
|
885
|
+
}
|
|
614
886
|
function registerInspectionTools(server, ctx) {
|
|
615
887
|
server.registerTool(
|
|
616
888
|
"human_screenshot",
|
|
@@ -631,7 +903,11 @@ function registerInspectionTools(server, ctx) {
|
|
|
631
903
|
async ({ selector, fullPage, filename, session }) => {
|
|
632
904
|
const { human, page } = await ctx.sessions.get(session);
|
|
633
905
|
const buffer = selector ? await page.locator(selector).screenshot() : await human.screenshot({ fullPage: fullPage ?? false });
|
|
634
|
-
const
|
|
906
|
+
const scope = selector ? `element ${selector}` : fullPage ? "full page" : "viewport";
|
|
907
|
+
const content = [
|
|
908
|
+
{ type: "image", data: buffer.toString("base64"), mimeType: "image/png" },
|
|
909
|
+
{ type: "text", text: `captured ${scope} of ${page.url()}` }
|
|
910
|
+
];
|
|
635
911
|
if (filename) {
|
|
636
912
|
const path = resolveOutputPath(ctx.env.outputDir, filename);
|
|
637
913
|
await writeFile(path, buffer);
|
|
@@ -673,15 +949,29 @@ function registerInspectionTools(server, ctx) {
|
|
|
673
949
|
"human_get_text",
|
|
674
950
|
{
|
|
675
951
|
title: "Get an element's text",
|
|
676
|
-
description: "Returns the
|
|
952
|
+
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.",
|
|
677
953
|
inputSchema: {
|
|
678
954
|
selector: z.string().describe("Selector of the element to read."),
|
|
955
|
+
all: allArg,
|
|
956
|
+
limit: matchLimitArg(TEXT_SWEEP_LIMIT),
|
|
679
957
|
session: sessionArg
|
|
680
958
|
}
|
|
681
959
|
},
|
|
682
|
-
async ({ selector, session }) => {
|
|
960
|
+
async ({ selector, all, limit, session }) => {
|
|
683
961
|
const { page } = await ctx.sessions.get(session);
|
|
684
|
-
|
|
962
|
+
if (!all) {
|
|
963
|
+
const text2 = await page.locator(selector).first().innerText();
|
|
964
|
+
return { content: [{ type: "text", text: text2 }] };
|
|
965
|
+
}
|
|
966
|
+
const elements = await page.locator(selector).all();
|
|
967
|
+
const shown = elements.slice(0, limit ?? TEXT_SWEEP_LIMIT);
|
|
968
|
+
const readings = await sweepRead(
|
|
969
|
+
shown,
|
|
970
|
+
(element) => element.innerText({ timeout: SWEEP_READ_TIMEOUT_MS })
|
|
971
|
+
);
|
|
972
|
+
const text = elements.length === 0 ? `No elements matched ${selector}` : `${sweepBody(readings, (value) => value)}
|
|
973
|
+
|
|
974
|
+
${sweepSummary(readings, elements.length, selector)}`;
|
|
685
975
|
return { content: [{ type: "text", text }] };
|
|
686
976
|
}
|
|
687
977
|
);
|
|
@@ -689,17 +979,31 @@ function registerInspectionTools(server, ctx) {
|
|
|
689
979
|
"human_get_attribute",
|
|
690
980
|
{
|
|
691
981
|
title: "Get an element's attribute",
|
|
692
|
-
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.",
|
|
982
|
+
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.",
|
|
693
983
|
inputSchema: {
|
|
694
984
|
selector: z.string().describe("Selector of the element."),
|
|
695
985
|
attribute: z.string().describe('Attribute name, e.g. "aria-label", "href", "data-state".'),
|
|
986
|
+
all: allArg,
|
|
987
|
+
limit: matchLimitArg(TEXT_SWEEP_LIMIT),
|
|
696
988
|
session: sessionArg
|
|
697
989
|
}
|
|
698
990
|
},
|
|
699
|
-
async ({ selector, attribute, session }) => {
|
|
991
|
+
async ({ selector, attribute, all, limit, session }) => {
|
|
700
992
|
const { page } = await ctx.sessions.get(session);
|
|
701
|
-
|
|
702
|
-
|
|
993
|
+
if (!all) {
|
|
994
|
+
const value = await page.locator(selector).first().getAttribute(attribute);
|
|
995
|
+
const text2 = value === null ? `${selector} has no attribute "${attribute}"` : `${attribute}="${value}"`;
|
|
996
|
+
return { content: [{ type: "text", text: text2 }] };
|
|
997
|
+
}
|
|
998
|
+
const elements = await page.locator(selector).all();
|
|
999
|
+
const shown = elements.slice(0, limit ?? TEXT_SWEEP_LIMIT);
|
|
1000
|
+
const readings = await sweepRead(
|
|
1001
|
+
shown,
|
|
1002
|
+
(element) => element.getAttribute(attribute, { timeout: SWEEP_READ_TIMEOUT_MS })
|
|
1003
|
+
);
|
|
1004
|
+
const text = elements.length === 0 ? `No elements matched ${selector}` : `${sweepBody(readings, (value) => value ?? "(absent)")}
|
|
1005
|
+
|
|
1006
|
+
${sweepSummary(readings, elements.length, selector)}`;
|
|
703
1007
|
return { content: [{ type: "text", text }] };
|
|
704
1008
|
}
|
|
705
1009
|
);
|
|
@@ -707,16 +1011,109 @@ function registerInspectionTools(server, ctx) {
|
|
|
707
1011
|
"human_get_html",
|
|
708
1012
|
{
|
|
709
1013
|
title: "Get an element's HTML",
|
|
710
|
-
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.",
|
|
1014
|
+
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.",
|
|
711
1015
|
inputSchema: {
|
|
712
1016
|
selector: z.string().describe("Selector of the region to dump. Target narrowly."),
|
|
1017
|
+
all: allArg,
|
|
1018
|
+
limit: matchLimitArg(HTML_SWEEP_LIMIT),
|
|
713
1019
|
session: sessionArg
|
|
714
1020
|
}
|
|
715
1021
|
},
|
|
716
|
-
async ({ selector, session }) => {
|
|
1022
|
+
async ({ selector, all, limit, session }) => {
|
|
717
1023
|
const { page } = await ctx.sessions.get(session);
|
|
718
|
-
|
|
719
|
-
|
|
1024
|
+
if (!all) {
|
|
1025
|
+
const html = await page.locator(selector).first().evaluate((el) => el.outerHTML);
|
|
1026
|
+
return { content: [{ type: "text", text: html }] };
|
|
1027
|
+
}
|
|
1028
|
+
const elements = await page.locator(selector).all();
|
|
1029
|
+
const shown = elements.slice(0, limit ?? HTML_SWEEP_LIMIT);
|
|
1030
|
+
const readings = await sweepRead(
|
|
1031
|
+
shown,
|
|
1032
|
+
(element) => element.evaluate((el) => el.outerHTML, void 0, { timeout: SWEEP_READ_TIMEOUT_MS })
|
|
1033
|
+
);
|
|
1034
|
+
const text = elements.length === 0 ? `No elements matched ${selector}` : `${sweepBody(readings, (value) => value, "\n\n")}
|
|
1035
|
+
|
|
1036
|
+
${sweepSummary(readings, elements.length, selector)}`;
|
|
1037
|
+
return { content: [{ type: "text", text }] };
|
|
1038
|
+
}
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
var sessionArg2 = z.string().optional().describe("Session ID to read from. Omit to use the default session.");
|
|
1042
|
+
var patternArg = z.string().optional().describe(
|
|
1043
|
+
'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$".'
|
|
1044
|
+
);
|
|
1045
|
+
var DEFAULT_LIMIT = 50;
|
|
1046
|
+
var limitArg = z.number().int().positive().optional().describe(
|
|
1047
|
+
`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.`
|
|
1048
|
+
);
|
|
1049
|
+
var clearArg = z.boolean().optional().describe(
|
|
1050
|
+
"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."
|
|
1051
|
+
);
|
|
1052
|
+
function withClearNote(text, discarded) {
|
|
1053
|
+
if (discarded === 0) return `${text}
|
|
1054
|
+
\u2014 buffer was already empty`;
|
|
1055
|
+
return `${text}
|
|
1056
|
+
\u2014 buffer cleared, ${discarded} entr${discarded === 1 ? "y" : "ies"} discarded`;
|
|
1057
|
+
}
|
|
1058
|
+
function registerObservabilityTools(server, { sessions }) {
|
|
1059
|
+
server.registerTool(
|
|
1060
|
+
"human_console_messages",
|
|
1061
|
+
{
|
|
1062
|
+
title: "Read the browser console",
|
|
1063
|
+
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.",
|
|
1064
|
+
inputSchema: {
|
|
1065
|
+
pattern: patternArg,
|
|
1066
|
+
onlyErrors: z.boolean().optional().describe(
|
|
1067
|
+
"Keep only console.error output and uncaught page errors, dropping log/info/warn/debug."
|
|
1068
|
+
),
|
|
1069
|
+
limit: limitArg,
|
|
1070
|
+
clear: clearArg,
|
|
1071
|
+
session: sessionArg2
|
|
1072
|
+
}
|
|
1073
|
+
},
|
|
1074
|
+
async ({ pattern, onlyErrors, limit, clear, session }) => {
|
|
1075
|
+
const { observers } = await sessions.get(session);
|
|
1076
|
+
const result = queryConsole(observers.console, {
|
|
1077
|
+
pattern,
|
|
1078
|
+
onlyErrors,
|
|
1079
|
+
limit: limit ?? DEFAULT_LIMIT
|
|
1080
|
+
});
|
|
1081
|
+
const formatted = formatConsole(result);
|
|
1082
|
+
const discarded = observers.console.size;
|
|
1083
|
+
if (clear) observers.console.clear();
|
|
1084
|
+
const text = clear ? withClearNote(formatted, discarded) : formatted;
|
|
1085
|
+
return { content: [{ type: "text", text }] };
|
|
1086
|
+
}
|
|
1087
|
+
);
|
|
1088
|
+
server.registerTool(
|
|
1089
|
+
"human_network_requests",
|
|
1090
|
+
{
|
|
1091
|
+
title: "Inspect network activity",
|
|
1092
|
+
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.',
|
|
1093
|
+
inputSchema: {
|
|
1094
|
+
pattern: patternArg,
|
|
1095
|
+
onlyFailures: z.boolean().optional().describe(
|
|
1096
|
+
"Keep only 4xx/5xx responses and requests that failed before responding. The fastest way to find a broken asset or API call."
|
|
1097
|
+
),
|
|
1098
|
+
status: z.number().int().optional().describe("Keep only responses with this exact HTTP status, e.g. 404 or 500."),
|
|
1099
|
+
limit: limitArg,
|
|
1100
|
+
clear: clearArg,
|
|
1101
|
+
session: sessionArg2
|
|
1102
|
+
}
|
|
1103
|
+
},
|
|
1104
|
+
async ({ pattern, onlyFailures, status, limit, clear, session }) => {
|
|
1105
|
+
const { observers } = await sessions.get(session);
|
|
1106
|
+
const result = queryNetwork(observers.network, {
|
|
1107
|
+
pattern,
|
|
1108
|
+
onlyFailures,
|
|
1109
|
+
status,
|
|
1110
|
+
limit: limit ?? DEFAULT_LIMIT
|
|
1111
|
+
});
|
|
1112
|
+
const formatted = formatNetwork(result);
|
|
1113
|
+
const discarded = observers.network.size;
|
|
1114
|
+
if (clear) observers.network.clear();
|
|
1115
|
+
const text = clear ? withClearNote(formatted, discarded) : formatted;
|
|
1116
|
+
return { content: [{ type: "text", text }] };
|
|
720
1117
|
}
|
|
721
1118
|
);
|
|
722
1119
|
}
|
|
@@ -744,7 +1141,7 @@ function resolveTarget(input) {
|
|
|
744
1141
|
}
|
|
745
1142
|
|
|
746
1143
|
// src/tools/primitives.ts
|
|
747
|
-
var
|
|
1144
|
+
var sessionArg3 = z.string().optional().describe(
|
|
748
1145
|
"Session ID to act on. Omit to use the default session (created lazily on first call). Use human_create_session for parallel browsers."
|
|
749
1146
|
);
|
|
750
1147
|
function registerPrimitiveTools(server, { sessions, env }) {
|
|
@@ -755,7 +1152,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
755
1152
|
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.`,
|
|
756
1153
|
inputSchema: {
|
|
757
1154
|
url: z.string().url().describe("Absolute URL to navigate to."),
|
|
758
|
-
session:
|
|
1155
|
+
session: sessionArg3
|
|
759
1156
|
}
|
|
760
1157
|
},
|
|
761
1158
|
async ({ url, session }) => {
|
|
@@ -769,7 +1166,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
769
1166
|
{
|
|
770
1167
|
title: "Click (humanized)",
|
|
771
1168
|
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.",
|
|
772
|
-
inputSchema: { ...targetFields, session:
|
|
1169
|
+
inputSchema: { ...targetFields, session: sessionArg3 }
|
|
773
1170
|
},
|
|
774
1171
|
async ({ selector, x, y, session }) => {
|
|
775
1172
|
const { human } = await sessions.get(session);
|
|
@@ -783,7 +1180,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
783
1180
|
{
|
|
784
1181
|
title: "Right-click (humanized)",
|
|
785
1182
|
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.",
|
|
786
|
-
inputSchema: { ...targetFields, session:
|
|
1183
|
+
inputSchema: { ...targetFields, session: sessionArg3 }
|
|
787
1184
|
},
|
|
788
1185
|
async ({ selector, x, y, session }) => {
|
|
789
1186
|
const { human } = await sessions.get(session);
|
|
@@ -799,7 +1196,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
799
1196
|
{
|
|
800
1197
|
title: "Double-click (humanized)",
|
|
801
1198
|
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.",
|
|
802
|
-
inputSchema: { ...targetFields, session:
|
|
1199
|
+
inputSchema: { ...targetFields, session: sessionArg3 }
|
|
803
1200
|
},
|
|
804
1201
|
async ({ selector, x, y, session }) => {
|
|
805
1202
|
const { human } = await sessions.get(session);
|
|
@@ -817,7 +1214,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
817
1214
|
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.",
|
|
818
1215
|
inputSchema: {
|
|
819
1216
|
selector: z.string().describe("Selector of the element to hover."),
|
|
820
|
-
session:
|
|
1217
|
+
session: sessionArg3
|
|
821
1218
|
}
|
|
822
1219
|
},
|
|
823
1220
|
async ({ selector, session }) => {
|
|
@@ -831,7 +1228,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
831
1228
|
{
|
|
832
1229
|
title: "Move the cursor (humanized)",
|
|
833
1230
|
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.",
|
|
834
|
-
inputSchema: { ...targetFields, session:
|
|
1231
|
+
inputSchema: { ...targetFields, session: sessionArg3 }
|
|
835
1232
|
},
|
|
836
1233
|
async ({ selector, x, y, session }) => {
|
|
837
1234
|
const { human } = await sessions.get(session);
|
|
@@ -852,7 +1249,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
852
1249
|
toSelector: z.string().optional().describe("Destination selector. Provide this OR toX/toY."),
|
|
853
1250
|
toX: z.number().optional().describe("Destination X coordinate. Requires toY."),
|
|
854
1251
|
toY: z.number().optional().describe("Destination Y coordinate. Requires toX."),
|
|
855
|
-
session:
|
|
1252
|
+
session: sessionArg3
|
|
856
1253
|
}
|
|
857
1254
|
},
|
|
858
1255
|
async ({ fromSelector, fromX, fromY, toSelector, toX, toY, session }) => {
|
|
@@ -878,7 +1275,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
878
1275
|
inputSchema: {
|
|
879
1276
|
selector: z.string().describe("Selector of the input/textarea/contenteditable."),
|
|
880
1277
|
value: z.string().describe("Text to type. May contain newlines."),
|
|
881
|
-
session:
|
|
1278
|
+
session: sessionArg3
|
|
882
1279
|
}
|
|
883
1280
|
},
|
|
884
1281
|
async ({ selector, value, session }) => {
|
|
@@ -895,7 +1292,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
895
1292
|
inputSchema: {
|
|
896
1293
|
selector: z.string().describe("Selector of the field to paste into."),
|
|
897
1294
|
value: z.string().describe("Text to insert."),
|
|
898
|
-
session:
|
|
1295
|
+
session: sessionArg3
|
|
899
1296
|
}
|
|
900
1297
|
},
|
|
901
1298
|
async ({ selector, value, session }) => {
|
|
@@ -911,7 +1308,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
911
1308
|
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.",
|
|
912
1309
|
inputSchema: {
|
|
913
1310
|
selector: z.string().describe("Selector of the field to clear."),
|
|
914
|
-
session:
|
|
1311
|
+
session: sessionArg3
|
|
915
1312
|
}
|
|
916
1313
|
},
|
|
917
1314
|
async ({ selector, session }) => {
|
|
@@ -928,7 +1325,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
928
1325
|
inputSchema: {
|
|
929
1326
|
selector: z.string().describe("Selector of the element whose text to select."),
|
|
930
1327
|
text: z.string().optional().describe("Optional substring to select instead of the element\u2019s whole text."),
|
|
931
|
-
session:
|
|
1328
|
+
session: sessionArg3
|
|
932
1329
|
}
|
|
933
1330
|
},
|
|
934
1331
|
async ({ selector, text, session }) => {
|
|
@@ -945,7 +1342,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
945
1342
|
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.",
|
|
946
1343
|
inputSchema: {
|
|
947
1344
|
selector: z.string().describe("Selector of the checkbox/radio input."),
|
|
948
|
-
session:
|
|
1345
|
+
session: sessionArg3
|
|
949
1346
|
}
|
|
950
1347
|
},
|
|
951
1348
|
async ({ selector, session }) => {
|
|
@@ -961,7 +1358,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
961
1358
|
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.",
|
|
962
1359
|
inputSchema: {
|
|
963
1360
|
selector: z.string().describe("Selector of the checkbox input."),
|
|
964
|
-
session:
|
|
1361
|
+
session: sessionArg3
|
|
965
1362
|
}
|
|
966
1363
|
},
|
|
967
1364
|
async ({ selector, session }) => {
|
|
@@ -978,7 +1375,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
978
1375
|
inputSchema: {
|
|
979
1376
|
selector: z.string().describe("Selector of the <select> element."),
|
|
980
1377
|
values: z.union([z.string(), z.array(z.string())]).describe("Option value, or array of values for a multi-select."),
|
|
981
|
-
session:
|
|
1378
|
+
session: sessionArg3
|
|
982
1379
|
}
|
|
983
1380
|
},
|
|
984
1381
|
async ({ selector, values, session }) => {
|
|
@@ -997,7 +1394,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
997
1394
|
inputSchema: {
|
|
998
1395
|
selector: z.string().describe("Selector of the file input."),
|
|
999
1396
|
files: z.union([z.string(), z.array(z.string())]).describe("Filename(s) inside HUMANJS_UPLOAD_DIR \u2014 a basename only, no path components."),
|
|
1000
|
-
session:
|
|
1397
|
+
session: sessionArg3
|
|
1001
1398
|
}
|
|
1002
1399
|
},
|
|
1003
1400
|
async ({ selector, files, session }) => {
|
|
@@ -1017,7 +1414,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
1017
1414
|
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.',
|
|
1018
1415
|
inputSchema: {
|
|
1019
1416
|
key: z.string().describe('Key or chord, e.g. "Enter", "Tab", "Mod+S", "Ctrl+Shift+K".'),
|
|
1020
|
-
session:
|
|
1417
|
+
session: sessionArg3
|
|
1021
1418
|
}
|
|
1022
1419
|
},
|
|
1023
1420
|
async ({ key, session }) => {
|
|
@@ -1039,7 +1436,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
1039
1436
|
to: z.number().optional().describe("Absolute scroll position on the chosen axis."),
|
|
1040
1437
|
axis: z.enum(["x", "y"]).optional().describe('Axis to scroll. Defaults to "y".'),
|
|
1041
1438
|
within: z.string().optional().describe("Selector of a scrollable container to scope the scroll to."),
|
|
1042
|
-
session:
|
|
1439
|
+
session: sessionArg3
|
|
1043
1440
|
}
|
|
1044
1441
|
},
|
|
1045
1442
|
async ({ target, by, to, axis, within, session }) => {
|
|
@@ -1062,7 +1459,7 @@ function registerPrimitiveTools(server, { sessions, env }) {
|
|
|
1062
1459
|
selector: z.string().optional().describe("Selector of the text to read. Provide this OR text."),
|
|
1063
1460
|
text: z.string().optional().describe('Literal text to "read" (no DOM lookup). Provide this OR selector.'),
|
|
1064
1461
|
kind: z.enum(["prose", "code", "scan"]).optional().describe('Reading style. Auto-detected as "code" for <pre>/<code> when omitted.'),
|
|
1065
|
-
session:
|
|
1462
|
+
session: sessionArg3
|
|
1066
1463
|
}
|
|
1067
1464
|
},
|
|
1068
1465
|
async ({ selector, text, kind, session }) => {
|
|
@@ -1226,6 +1623,12 @@ Export as a test: human_stop_recording picks format by extension. A .spec.ts (or
|
|
|
1226
1623
|
|
|
1227
1624
|
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.
|
|
1228
1625
|
|
|
1626
|
+
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.
|
|
1627
|
+
|
|
1628
|
+
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.
|
|
1629
|
+
|
|
1630
|
+
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.
|
|
1631
|
+
|
|
1229
1632
|
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.
|
|
1230
1633
|
|
|
1231
1634
|
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.`;
|
|
@@ -1239,6 +1642,7 @@ async function main() {
|
|
|
1239
1642
|
);
|
|
1240
1643
|
registerPrimitiveTools(server, ctx);
|
|
1241
1644
|
registerInspectionTools(server, ctx);
|
|
1645
|
+
registerObservabilityTools(server, ctx);
|
|
1242
1646
|
registerRecordingTools(server, ctx);
|
|
1243
1647
|
registerSessionTools(server, ctx);
|
|
1244
1648
|
registerConfigTools(server, ctx);
|