@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/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 content = [{ type: "image", data: buffer.toString("base64"), mimeType: "image/png" }];
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 visible innerText of the first element matching `selector`. Use to read a specific label, price, status, or message.",
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
- const text = await page.locator(selector).innerText();
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
- const value = await page.locator(selector).getAttribute(attribute);
705
- const text = value === null ? `${selector} has no attribute "${attribute}"` : `${attribute}="${value}"`;
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
- const html = await page.locator(selector).evaluate((el) => el.outerHTML);
722
- return { content: [{ type: "text", text: html }] };
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 sessionArg2 = zod.z.string().optional().describe(
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: sessionArg2
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: sessionArg2 }
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: sessionArg2 }
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: sessionArg2 }
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: sessionArg2
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: sessionArg2 }
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: sessionArg2
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: sessionArg2
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: sessionArg2
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: sessionArg2
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: sessionArg2
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: sessionArg2
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: sessionArg2
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: sessionArg2
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: sessionArg2
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: sessionArg2
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: sessionArg2
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: sessionArg2
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);