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