@geometra/mcp 1.19.10 → 1.19.12

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/server.js CHANGED
@@ -21,6 +21,7 @@ function nodeFilterShape() {
21
21
  role: z.string().optional().describe('ARIA role to match'),
22
22
  name: z.string().optional().describe('Accessible name to match (exact or substring)'),
23
23
  text: z.string().optional().describe('Text content to search for (substring match)'),
24
+ contextText: z.string().optional().describe('Ancestor / prompt text to disambiguate repeated controls with the same visible name'),
24
25
  value: z.string().optional().describe('Displayed / current field value to match (substring match)'),
25
26
  checked: checkedStateInput(),
26
27
  disabled: z.boolean().optional().describe('Match disabled state'),
@@ -145,7 +146,7 @@ const batchActionSchema = z.discriminatedUnion('type', [
145
146
  }),
146
147
  ]);
147
148
  export function createServer() {
148
- const server = new McpServer({ name: 'geometra', version: '1.19.10' }, { capabilities: { tools: {} } });
149
+ const server = new McpServer({ name: 'geometra', version: '1.19.11' }, { capabilities: { tools: {} } });
149
150
  // ── connect ──────────────────────────────────────────────────
150
151
  server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
151
152
 
@@ -213,7 +214,7 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
213
214
  // ── query ────────────────────────────────────────────────────
214
215
  server.tool('geometra_query', `Find elements in the current Geometra UI by stable id, role, name, text content, current value, or semantic state. Returns matching elements with their exact pixel bounds {x, y, width, height}, visible in-viewport bounds, an on-screen center point, visibility / scroll-reveal hints, role, name, value, state, and tree path.
215
216
 
216
- This is the Geometra equivalent of Playwright's locator — but instant, structured, and with no browser. Use the returned bounds to click elements or assert on layout.`, nodeFilterShape(), async ({ id, role, name, text, value, checked, disabled, focused, selected, expanded, invalid, required, busy }) => {
217
+ This is the Geometra equivalent of Playwright's locator — but instant, structured, and with no browser. Use the returned bounds to click elements or assert on layout.`, nodeFilterShape(), async ({ id, role, name, text, contextText, value, checked, disabled, focused, selected, expanded, invalid, required, busy }) => {
217
218
  const session = getSession();
218
219
  if (!session?.tree || !session?.layout)
219
220
  return err('Not connected. Call geometra_connect first.');
@@ -223,6 +224,7 @@ This is the Geometra equivalent of Playwright's locator — but instant, structu
223
224
  role,
224
225
  name,
225
226
  text,
227
+ contextText,
226
228
  value,
227
229
  checked,
228
230
  disabled,
@@ -234,12 +236,12 @@ This is the Geometra equivalent of Playwright's locator — but instant, structu
234
236
  busy,
235
237
  };
236
238
  if (!hasNodeFilter(filter))
237
- return err('Provide at least one query filter (id, role, name, text, value, or state)');
239
+ return err('Provide at least one query filter (id, role, name, text, contextText, value, or state)');
238
240
  const matches = findNodes(a11y, filter);
239
241
  if (matches.length === 0) {
240
242
  return ok(`No elements found matching ${JSON.stringify(filter)}`);
241
243
  }
242
- const result = matches.map(node => formatNode(node, a11y.bounds));
244
+ const result = sortA11yNodes(matches).map(node => formatNode(node, a11y, a11y.bounds));
243
245
  return ok(JSON.stringify(result, null, 2));
244
246
  });
245
247
  server.tool('geometra_wait_for', `Wait for a semantic UI condition without guessing sleep durations. Use this for slow SPA transitions, resume parsing, custom validation alerts, disabled submit buttons, and value/state confirmation before submit.
@@ -255,7 +257,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
255
257
  .optional()
256
258
  .default(10_000)
257
259
  .describe('Maximum time to wait before returning an error (default 10000ms)'),
258
- }, async ({ id, role, name, text, value, checked, disabled, focused, selected, expanded, invalid, required, busy, present, timeoutMs }) => {
260
+ }, async ({ id, role, name, text, contextText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, present, timeoutMs }) => {
259
261
  const session = getSession();
260
262
  if (!session?.tree || !session?.layout)
261
263
  return err('Not connected. Call geometra_connect first.');
@@ -264,6 +266,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
264
266
  role,
265
267
  name,
266
268
  text,
269
+ contextText,
267
270
  value,
268
271
  checked,
269
272
  disabled,
@@ -275,7 +278,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
275
278
  busy,
276
279
  };
277
280
  if (!hasNodeFilter(filter))
278
- return err('Provide at least one wait filter (id, role, name, text, value, or state)');
281
+ return err('Provide at least one wait filter (id, role, name, text, contextText, value, or state)');
279
282
  const matchesCondition = () => {
280
283
  if (!session.tree || !session.layout)
281
284
  return false;
@@ -296,7 +299,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
296
299
  if (!after)
297
300
  return ok(`Condition satisfied after ${elapsedMs}ms for ${JSON.stringify(filter)}.`);
298
301
  const matches = findNodes(after, filter);
299
- const result = matches.slice(0, 8).map(node => formatNode(node, after.bounds));
302
+ const result = sortA11yNodes(matches).slice(0, 8).map(node => formatNode(node, after, after.bounds));
300
303
  return ok(JSON.stringify(result, null, 2));
301
304
  });
302
305
  server.tool('geometra_fill_fields', `Fill several labeled form fields in one MCP call. This is the preferred high-level primitive for long forms.
@@ -309,8 +312,13 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
309
312
  .optional()
310
313
  .default(false)
311
314
  .describe('Return an error if invalid fields remain after filling'),
315
+ includeSteps: z
316
+ .boolean()
317
+ .optional()
318
+ .default(true)
319
+ .describe('Include per-field step results in the JSON payload (default true). Set false for the smallest batch response.'),
312
320
  detail: detailInput(),
313
- }, async ({ fields, stopOnError, failOnInvalid, detail }) => {
321
+ }, async ({ fields, stopOnError, failOnInvalid, includeSteps, detail }) => {
314
322
  const session = getSession();
315
323
  if (!session)
316
324
  return err('Not connected. Call geometra_connect first.');
@@ -319,8 +327,10 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
319
327
  for (let index = 0; index < fields.length; index++) {
320
328
  const field = fields[index];
321
329
  try {
322
- const summary = await executeFillField(session, field, detail);
323
- steps.push({ index, kind: field.kind, ok: true, summary });
330
+ const result = await executeFillField(session, field, detail);
331
+ steps.push(detail === 'verbose'
332
+ ? { index, kind: field.kind, ok: true, summary: result.summary }
333
+ : { index, kind: field.kind, ok: true, ...result.compact });
324
334
  }
325
335
  catch (e) {
326
336
  const message = e instanceof Error ? e.message : String(e);
@@ -334,12 +344,16 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
334
344
  const after = sessionA11y(session);
335
345
  const signals = after ? collectSessionSignals(after) : undefined;
336
346
  const invalidRemaining = signals?.invalidFields.length ?? 0;
347
+ const successCount = steps.filter(step => step.ok === true).length;
348
+ const errorCount = steps.length - successCount;
337
349
  const payload = {
338
350
  completed: stoppedAt === undefined && steps.length === fields.length,
339
351
  fieldCount: fields.length,
340
- steps,
352
+ successCount,
353
+ errorCount,
354
+ ...(includeSteps ? { steps } : {}),
341
355
  ...(stoppedAt !== undefined ? { stoppedAt } : {}),
342
- ...(signals ? { final: sessionSignalsPayload(signals) } : {}),
356
+ ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
343
357
  };
344
358
  if (failOnInvalid && invalidRemaining > 0) {
345
359
  return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
@@ -351,8 +365,13 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
351
365
  Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_listbox_option\`, \`select_option\`, \`set_checked\`, \`wheel\`, \`wait_for\`, and \`fill_fields\`.`, {
352
366
  actions: z.array(batchActionSchema).min(1).max(80).describe('Ordered high-level action steps to run sequentially'),
353
367
  stopOnError: z.boolean().optional().default(true).describe('Stop at the first failing step (default true)'),
368
+ includeSteps: z
369
+ .boolean()
370
+ .optional()
371
+ .default(true)
372
+ .describe('Include per-action step results in the JSON payload (default true). Set false for the smallest batch response.'),
354
373
  detail: detailInput(),
355
- }, async ({ actions, stopOnError, detail }) => {
374
+ }, async ({ actions, stopOnError, includeSteps, detail }) => {
356
375
  const session = getSession();
357
376
  if (!session)
358
377
  return err('Not connected. Call geometra_connect first.');
@@ -361,8 +380,10 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
361
380
  for (let index = 0; index < actions.length; index++) {
362
381
  const action = actions[index];
363
382
  try {
364
- const summary = await executeBatchAction(session, action, detail);
365
- steps.push({ index, type: action.type, ok: true, summary });
383
+ const result = await executeBatchAction(session, action, detail, includeSteps);
384
+ steps.push(detail === 'verbose'
385
+ ? { index, type: action.type, ok: true, summary: result.summary }
386
+ : { index, type: action.type, ok: true, ...result.compact });
366
387
  }
367
388
  catch (e) {
368
389
  const message = e instanceof Error ? e.message : String(e);
@@ -374,12 +395,16 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
374
395
  }
375
396
  }
376
397
  const after = sessionA11y(session);
398
+ const successCount = steps.filter(step => step.ok === true).length;
399
+ const errorCount = steps.length - successCount;
377
400
  const payload = {
378
401
  completed: stoppedAt === undefined && steps.length === actions.length,
379
402
  stepCount: actions.length,
380
- steps,
403
+ successCount,
404
+ errorCount,
405
+ ...(includeSteps ? { steps } : {}),
381
406
  ...(stoppedAt !== undefined ? { stoppedAt } : {}),
382
- ...(after ? { final: sessionSignalsPayload(collectSessionSignals(after)) } : {}),
407
+ ...(after ? { final: sessionSignalsPayload(collectSessionSignals(after), detail) } : {}),
383
408
  };
384
409
  return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
385
410
  });
@@ -417,12 +442,18 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
417
442
  id: z.string().describe('Section id from geometra_page_model, e.g. fm:1.0 or ls:2.1'),
418
443
  maxHeadings: z.number().int().min(1).max(20).optional().default(6).describe('Cap heading rows'),
419
444
  maxFields: z.number().int().min(1).max(40).optional().default(18).describe('Cap field rows'),
445
+ fieldOffset: z.number().int().min(0).optional().default(0).describe('Field row offset for long forms'),
446
+ onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields'),
447
+ onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields'),
420
448
  maxActions: z.number().int().min(1).max(30).optional().default(12).describe('Cap action rows'),
449
+ actionOffset: z.number().int().min(0).optional().default(0).describe('Action row offset'),
421
450
  maxLists: z.number().int().min(0).max(20).optional().default(8).describe('Cap nested lists'),
451
+ listOffset: z.number().int().min(0).optional().default(0).describe('Nested-list offset'),
422
452
  maxItems: z.number().int().min(0).max(50).optional().default(20).describe('Cap list items'),
453
+ itemOffset: z.number().int().min(0).optional().default(0).describe('List-item offset'),
423
454
  maxTextPreview: z.number().int().min(0).max(20).optional().default(6).describe('Cap text preview lines'),
424
455
  includeBounds: z.boolean().optional().default(false).describe('Include bounds for fields/actions/headings/items'),
425
- }, async ({ id, maxHeadings, maxFields, maxActions, maxLists, maxItems, maxTextPreview, includeBounds }) => {
456
+ }, async ({ id, maxHeadings, maxFields, fieldOffset, onlyRequiredFields, onlyInvalidFields, maxActions, actionOffset, maxLists, listOffset, maxItems, itemOffset, maxTextPreview, includeBounds, }) => {
426
457
  const session = getSession();
427
458
  if (!session?.tree || !session?.layout)
428
459
  return err('Not connected. Call geometra_connect first.');
@@ -430,9 +461,15 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
430
461
  const detail = expandPageSection(a11y, id, {
431
462
  maxHeadings,
432
463
  maxFields,
464
+ fieldOffset,
465
+ onlyRequiredFields,
466
+ onlyInvalidFields,
433
467
  maxActions,
468
+ actionOffset,
434
469
  maxLists,
470
+ listOffset,
435
471
  maxItems,
472
+ itemOffset,
436
473
  maxTextPreview,
437
474
  includeBounds,
438
475
  });
@@ -440,6 +477,90 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
440
477
  return err(`No expandable section found for id ${id}`);
441
478
  return ok(JSON.stringify(detail));
442
479
  });
480
+ server.tool('geometra_reveal', `Scroll until a matching node is revealed. This is the generic alternative to trial-and-error wheel calls on long forms.
481
+
482
+ Use the same filters as geometra_query, plus an optional match index when repeated controls share the same visible label.`, {
483
+ ...nodeFilterShape(),
484
+ index: z.number().int().min(0).optional().default(0).describe('Which matching node to reveal after sorting top-to-bottom'),
485
+ fullyVisible: z.boolean().optional().default(true).describe('Require the target to become fully visible (default true)'),
486
+ maxSteps: z.number().int().min(1).max(12).optional().default(6).describe('Maximum reveal attempts before returning an error'),
487
+ timeoutMs: z
488
+ .number()
489
+ .int()
490
+ .min(50)
491
+ .max(60_000)
492
+ .optional()
493
+ .default(2_500)
494
+ .describe('Per-scroll wait timeout (default 2500ms)'),
495
+ }, async ({ id, role, name, text, contextText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, index, fullyVisible, maxSteps, timeoutMs }) => {
496
+ const session = getSession();
497
+ if (!session)
498
+ return err('Not connected. Call geometra_connect first.');
499
+ const matchIndex = index ?? 0;
500
+ const requireFullyVisible = fullyVisible ?? true;
501
+ const revealSteps = maxSteps ?? 6;
502
+ const waitTimeout = timeoutMs ?? 2_500;
503
+ const filter = {
504
+ id,
505
+ role,
506
+ name,
507
+ text,
508
+ contextText,
509
+ value,
510
+ checked,
511
+ disabled,
512
+ focused,
513
+ selected,
514
+ expanded,
515
+ invalid,
516
+ required,
517
+ busy,
518
+ };
519
+ if (!hasNodeFilter(filter))
520
+ return err('Provide at least one reveal filter (id, role, name, text, contextText, value, or state)');
521
+ let attempts = 0;
522
+ while (attempts <= revealSteps) {
523
+ const a11y = sessionA11y(session);
524
+ if (!a11y)
525
+ return err('No UI tree available to reveal from');
526
+ const matches = sortA11yNodes(findNodes(a11y, filter));
527
+ if (matches.length === 0) {
528
+ return err(`No elements found matching ${JSON.stringify(filter)}`);
529
+ }
530
+ if (matchIndex >= matches.length) {
531
+ return err(`Requested reveal index ${matchIndex} but only ${matches.length} matching element(s) were found`);
532
+ }
533
+ const target = matches[matchIndex];
534
+ const formatted = formatNode(target, a11y, a11y.bounds);
535
+ const visible = requireFullyVisible ? formatted.visibility.fullyVisible : formatted.visibility.intersectsViewport;
536
+ if (visible) {
537
+ return ok(JSON.stringify({
538
+ revealed: true,
539
+ attempts,
540
+ target: formatted,
541
+ }, null, 2));
542
+ }
543
+ if (attempts === revealSteps) {
544
+ return err(JSON.stringify({
545
+ revealed: false,
546
+ attempts,
547
+ target: formatted,
548
+ }, null, 2));
549
+ }
550
+ const deltaX = clamp(formatted.scrollHint.revealDeltaX, -Math.round(a11y.bounds.width * 0.75), Math.round(a11y.bounds.width * 0.75));
551
+ let deltaY = clamp(formatted.scrollHint.revealDeltaY, -Math.round(a11y.bounds.height * 0.85), Math.round(a11y.bounds.height * 0.85));
552
+ if (deltaY === 0 && !formatted.visibility.fullyVisible) {
553
+ deltaY = formatted.visibility.offscreenAbove ? -Math.round(a11y.bounds.height * 0.4) : Math.round(a11y.bounds.height * 0.4);
554
+ }
555
+ await sendWheel(session, deltaY, {
556
+ deltaX,
557
+ x: formatted.center.x,
558
+ y: formatted.center.y,
559
+ }, waitTimeout);
560
+ attempts++;
561
+ }
562
+ return err(`Failed to reveal ${JSON.stringify(filter)}`);
563
+ });
443
564
  // ── click ────────────────────────────────────────────────────
444
565
  server.tool('geometra_click', `Click an element in the Geometra UI. Provide either the element's bounds (from geometra_query) or raw x,y coordinates. The click is dispatched server-side via the geometry protocol — no browser, no simulated DOM events.
445
566
 
@@ -897,7 +1018,7 @@ function truncateInlineText(text, max) {
897
1018
  return undefined;
898
1019
  return normalized.length > max ? `${normalized.slice(0, max - 1)}…` : normalized;
899
1020
  }
900
- function sessionSignalsPayload(signals) {
1021
+ function sessionSignalsPayload(signals, detail = 'minimal') {
901
1022
  return {
902
1023
  ...(signals.pageUrl ? { pageUrl: signals.pageUrl } : {}),
903
1024
  ...(signals.scrollX !== undefined || signals.scrollY !== undefined
@@ -906,26 +1027,85 @@ function sessionSignalsPayload(signals) {
906
1027
  ...(signals.focus ? { focus: signals.focus } : {}),
907
1028
  dialogCount: signals.dialogCount,
908
1029
  busyCount: signals.busyCount,
909
- alerts: signals.alerts,
910
- invalidFields: signals.invalidFields,
1030
+ alertCount: signals.alerts.length,
1031
+ invalidCount: signals.invalidFields.length,
1032
+ alerts: detail === 'verbose' ? signals.alerts : signals.alerts.slice(0, 2),
1033
+ invalidFields: detail === 'verbose' ? signals.invalidFields : signals.invalidFields.slice(0, 4),
1034
+ };
1035
+ }
1036
+ function compactTextValue(value, inlineLimit = 48) {
1037
+ const normalized = value.replace(/\s+/g, ' ').trim();
1038
+ if (!normalized)
1039
+ return { valueLength: value.length };
1040
+ return normalized.length <= inlineLimit
1041
+ ? { value: normalized }
1042
+ : { valueLength: value.length };
1043
+ }
1044
+ function fieldStatePayload(session, fieldLabel) {
1045
+ const a11y = sessionA11y(session);
1046
+ if (!a11y)
1047
+ return undefined;
1048
+ const matches = findNodes(a11y, {
1049
+ name: fieldLabel,
1050
+ role: 'combobox',
1051
+ });
1052
+ if (matches.length === 0) {
1053
+ matches.push(...findNodes(a11y, { name: fieldLabel, role: 'textbox' }));
1054
+ }
1055
+ if (matches.length === 0) {
1056
+ matches.push(...findNodes(a11y, { name: fieldLabel, role: 'button' }));
1057
+ }
1058
+ const match = matches[0];
1059
+ if (!match)
1060
+ return undefined;
1061
+ const valuePayload = match.value ? compactTextValue(match.value, 64) : {};
1062
+ return {
1063
+ role: match.role,
1064
+ ...valuePayload,
1065
+ ...(match.state && Object.keys(match.state).length > 0 ? { state: match.state } : {}),
1066
+ ...(match.validation?.error ? { error: truncateInlineText(match.validation.error, 120) } : {}),
911
1067
  };
912
1068
  }
913
- async function executeBatchAction(session, action, detail) {
1069
+ function waitStatusPayload(wait) {
1070
+ return wait ? { wait: wait.status } : {};
1071
+ }
1072
+ function compactFilterPayload(filter) {
1073
+ return Object.fromEntries(Object.entries(filter).filter(([, value]) => value !== undefined));
1074
+ }
1075
+ async function executeBatchAction(session, action, detail, includeSteps) {
914
1076
  switch (action.type) {
915
1077
  case 'click': {
916
1078
  const before = sessionA11y(session);
917
1079
  const wait = await sendClick(session, action.x, action.y, action.timeoutMs);
918
- return `Clicked at (${action.x}, ${action.y}).\n${postActionSummary(session, before, wait, detail)}`;
1080
+ return {
1081
+ summary: `Clicked at (${action.x}, ${action.y}).\n${postActionSummary(session, before, wait, detail)}`,
1082
+ compact: {
1083
+ at: { x: action.x, y: action.y },
1084
+ ...waitStatusPayload(wait),
1085
+ },
1086
+ };
919
1087
  }
920
1088
  case 'type': {
921
1089
  const before = sessionA11y(session);
922
1090
  const wait = await sendType(session, action.text, action.timeoutMs);
923
- return `Typed "${action.text}".\n${postActionSummary(session, before, wait, detail)}`;
1091
+ return {
1092
+ summary: `Typed "${action.text}".\n${postActionSummary(session, before, wait, detail)}`,
1093
+ compact: {
1094
+ ...compactTextValue(action.text),
1095
+ ...waitStatusPayload(wait),
1096
+ },
1097
+ };
924
1098
  }
925
1099
  case 'key': {
926
1100
  const before = sessionA11y(session);
927
1101
  const wait = await sendKey(session, action.key, { shift: action.shift, ctrl: action.ctrl, meta: action.meta, alt: action.alt }, action.timeoutMs);
928
- return `Pressed ${formatKeyCombo(action.key, action)}.\n${postActionSummary(session, before, wait, detail)}`;
1102
+ return {
1103
+ summary: `Pressed ${formatKeyCombo(action.key, action)}.\n${postActionSummary(session, before, wait, detail)}`,
1104
+ compact: {
1105
+ key: formatKeyCombo(action.key, action),
1106
+ ...waitStatusPayload(wait),
1107
+ },
1108
+ };
929
1109
  }
930
1110
  case 'upload_files': {
931
1111
  const before = sessionA11y(session);
@@ -936,7 +1116,16 @@ async function executeBatchAction(session, action, detail) {
936
1116
  strategy: action.strategy,
937
1117
  drop: action.dropX !== undefined && action.dropY !== undefined ? { x: action.dropX, y: action.dropY } : undefined,
938
1118
  }, action.timeoutMs ?? 8_000);
939
- return `Uploaded ${action.paths.length} file(s).\n${postActionSummary(session, before, wait, detail)}`;
1119
+ return {
1120
+ summary: `Uploaded ${action.paths.length} file(s).\n${postActionSummary(session, before, wait, detail)}`,
1121
+ compact: {
1122
+ fileCount: action.paths.length,
1123
+ ...(action.fieldLabel ? { fieldLabel: action.fieldLabel } : {}),
1124
+ ...(action.strategy ? { strategy: action.strategy } : {}),
1125
+ ...waitStatusPayload(wait),
1126
+ ...(action.fieldLabel ? { readback: fieldStatePayload(session, action.fieldLabel) } : {}),
1127
+ },
1128
+ };
940
1129
  }
941
1130
  case 'pick_listbox_option': {
942
1131
  const before = sessionA11y(session);
@@ -948,7 +1137,15 @@ async function executeBatchAction(session, action, detail) {
948
1137
  }, action.timeoutMs);
949
1138
  const summary = postActionSummary(session, before, wait, detail);
950
1139
  const fieldSummary = action.fieldLabel ? summarizeFieldLabelState(session, action.fieldLabel) : undefined;
951
- return [`Picked listbox option "${action.label}".`, fieldSummary, summary].filter(Boolean).join('\n');
1140
+ return {
1141
+ summary: [`Picked listbox option "${action.label}".`, fieldSummary, summary].filter(Boolean).join('\n'),
1142
+ compact: {
1143
+ label: action.label,
1144
+ ...(action.fieldLabel ? { fieldLabel: action.fieldLabel } : {}),
1145
+ ...waitStatusPayload(wait),
1146
+ ...(action.fieldLabel ? { readback: fieldStatePayload(session, action.fieldLabel) } : {}),
1147
+ },
1148
+ };
952
1149
  }
953
1150
  case 'select_option': {
954
1151
  if (action.value === undefined && action.label === undefined && action.index === undefined) {
@@ -960,7 +1157,16 @@ async function executeBatchAction(session, action, detail) {
960
1157
  label: action.label,
961
1158
  index: action.index,
962
1159
  }, action.timeoutMs);
963
- return `Selected option.\n${postActionSummary(session, before, wait, detail)}`;
1160
+ return {
1161
+ summary: `Selected option.\n${postActionSummary(session, before, wait, detail)}`,
1162
+ compact: {
1163
+ at: { x: action.x, y: action.y },
1164
+ ...(action.value !== undefined ? { value: action.value } : {}),
1165
+ ...(action.label !== undefined ? { label: action.label } : {}),
1166
+ ...(action.index !== undefined ? { index: action.index } : {}),
1167
+ ...waitStatusPayload(wait),
1168
+ },
1169
+ };
964
1170
  }
965
1171
  case 'set_checked': {
966
1172
  const before = sessionA11y(session);
@@ -969,7 +1175,15 @@ async function executeBatchAction(session, action, detail) {
969
1175
  exact: action.exact,
970
1176
  controlType: action.controlType,
971
1177
  }, action.timeoutMs);
972
- return `Set ${action.controlType ?? 'checkbox/radio'} "${action.label}" to ${String(action.checked ?? true)}.\n${postActionSummary(session, before, wait, detail)}`;
1178
+ return {
1179
+ summary: `Set ${action.controlType ?? 'checkbox/radio'} "${action.label}" to ${String(action.checked ?? true)}.\n${postActionSummary(session, before, wait, detail)}`,
1180
+ compact: {
1181
+ label: action.label,
1182
+ checked: action.checked ?? true,
1183
+ ...(action.controlType ? { controlType: action.controlType } : {}),
1184
+ ...waitStatusPayload(wait),
1185
+ },
1186
+ };
973
1187
  }
974
1188
  case 'wheel': {
975
1189
  const before = sessionA11y(session);
@@ -978,7 +1192,15 @@ async function executeBatchAction(session, action, detail) {
978
1192
  x: action.x,
979
1193
  y: action.y,
980
1194
  }, action.timeoutMs);
981
- return `Wheel delta (${action.deltaX ?? 0}, ${action.deltaY}).\n${postActionSummary(session, before, wait, detail)}`;
1195
+ return {
1196
+ summary: `Wheel delta (${action.deltaX ?? 0}, ${action.deltaY}).\n${postActionSummary(session, before, wait, detail)}`,
1197
+ compact: {
1198
+ deltaY: action.deltaY,
1199
+ ...(action.deltaX !== undefined ? { deltaX: action.deltaX } : {}),
1200
+ ...(action.x !== undefined && action.y !== undefined ? { at: { x: action.x, y: action.y } } : {}),
1201
+ ...waitStatusPayload(wait),
1202
+ },
1203
+ };
982
1204
  }
983
1205
  case 'wait_for': {
984
1206
  if (!session.tree || !session.layout)
@@ -988,6 +1210,7 @@ async function executeBatchAction(session, action, detail) {
988
1210
  role: action.role,
989
1211
  name: action.name,
990
1212
  text: action.text,
1213
+ contextText: action.contextText,
991
1214
  value: action.value,
992
1215
  checked: action.checked,
993
1216
  disabled: action.disabled,
@@ -1016,24 +1239,64 @@ async function executeBatchAction(session, action, detail) {
1016
1239
  throw new Error(`Timed out after ${timeoutMs}ms waiting for ${present ? 'presence' : 'absence'} of ${JSON.stringify(filter)}`);
1017
1240
  }
1018
1241
  if (!present) {
1019
- return `Condition satisfied after ${elapsedMs}ms: no nodes matched ${JSON.stringify(filter)}.`;
1242
+ return {
1243
+ summary: `Condition satisfied after ${elapsedMs}ms: no nodes matched ${JSON.stringify(filter)}.`,
1244
+ compact: {
1245
+ present,
1246
+ elapsedMs,
1247
+ filter: compactFilterPayload(filter),
1248
+ },
1249
+ };
1020
1250
  }
1021
1251
  const after = sessionA11y(session);
1022
1252
  if (!after) {
1023
- return `Condition satisfied after ${elapsedMs}ms for ${JSON.stringify(filter)}.`;
1253
+ return {
1254
+ summary: `Condition satisfied after ${elapsedMs}ms for ${JSON.stringify(filter)}.`,
1255
+ compact: {
1256
+ present,
1257
+ elapsedMs,
1258
+ filter: compactFilterPayload(filter),
1259
+ },
1260
+ };
1024
1261
  }
1025
1262
  const matches = findNodes(after, filter);
1026
1263
  if (detail === 'verbose') {
1027
- return JSON.stringify(matches.slice(0, 8).map(node => formatNode(node, after.bounds)), null, 2);
1264
+ return {
1265
+ summary: JSON.stringify(sortA11yNodes(matches).slice(0, 8).map(node => formatNode(node, after, after.bounds)), null, 2),
1266
+ compact: {
1267
+ present,
1268
+ elapsedMs,
1269
+ matchCount: matches.length,
1270
+ filter: compactFilterPayload(filter),
1271
+ },
1272
+ };
1028
1273
  }
1029
- return `Condition satisfied after ${elapsedMs}ms with ${matches.length} matching node(s).`;
1274
+ return {
1275
+ summary: `Condition satisfied after ${elapsedMs}ms with ${matches.length} matching node(s).`,
1276
+ compact: {
1277
+ present,
1278
+ elapsedMs,
1279
+ matchCount: matches.length,
1280
+ filter: compactFilterPayload(filter),
1281
+ },
1282
+ };
1030
1283
  }
1031
1284
  case 'fill_fields': {
1032
- const lines = [];
1033
- for (const field of action.fields) {
1034
- lines.push(await executeFillField(session, field, detail));
1285
+ const steps = [];
1286
+ for (let index = 0; index < action.fields.length; index++) {
1287
+ const field = action.fields[index];
1288
+ const result = await executeFillField(session, field, detail);
1289
+ steps.push(detail === 'verbose'
1290
+ ? { index, kind: field.kind, ok: true, summary: result.summary }
1291
+ : { index, kind: field.kind, ok: true, ...result.compact });
1035
1292
  }
1036
- return lines.join('\n');
1293
+ return {
1294
+ summary: steps.map(step => String(step.summary ?? '')).filter(Boolean).join('\n'),
1295
+ compact: {
1296
+ fieldCount: action.fields.length,
1297
+ ...(includeSteps ? { steps } : {}),
1298
+ },
1299
+ };
1037
1300
  }
1038
1301
  }
1039
1302
  }
@@ -1043,36 +1306,68 @@ async function executeFillField(session, field, detail) {
1043
1306
  const before = sessionA11y(session);
1044
1307
  const wait = await sendFieldText(session, field.fieldLabel, field.value, { exact: field.exact }, field.timeoutMs);
1045
1308
  const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
1046
- return [
1047
- `Filled text field "${field.fieldLabel}".`,
1048
- fieldSummary,
1049
- postActionSummary(session, before, wait, detail),
1050
- ].filter(Boolean).join('\n');
1309
+ return {
1310
+ summary: [
1311
+ `Filled text field "${field.fieldLabel}".`,
1312
+ fieldSummary,
1313
+ postActionSummary(session, before, wait, detail),
1314
+ ].filter(Boolean).join('\n'),
1315
+ compact: {
1316
+ fieldLabel: field.fieldLabel,
1317
+ ...compactTextValue(field.value),
1318
+ ...waitStatusPayload(wait),
1319
+ readback: fieldStatePayload(session, field.fieldLabel),
1320
+ },
1321
+ };
1051
1322
  }
1052
1323
  case 'choice': {
1053
1324
  const before = sessionA11y(session);
1054
1325
  const wait = await sendFieldChoice(session, field.fieldLabel, field.value, { exact: field.exact, query: field.query }, field.timeoutMs);
1055
1326
  const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
1056
- return [
1057
- `Set choice field "${field.fieldLabel}" to "${field.value}".`,
1058
- fieldSummary,
1059
- postActionSummary(session, before, wait, detail),
1060
- ].filter(Boolean).join('\n');
1327
+ return {
1328
+ summary: [
1329
+ `Set choice field "${field.fieldLabel}" to "${field.value}".`,
1330
+ fieldSummary,
1331
+ postActionSummary(session, before, wait, detail),
1332
+ ].filter(Boolean).join('\n'),
1333
+ compact: {
1334
+ fieldLabel: field.fieldLabel,
1335
+ value: field.value,
1336
+ ...waitStatusPayload(wait),
1337
+ readback: fieldStatePayload(session, field.fieldLabel),
1338
+ },
1339
+ };
1061
1340
  }
1062
1341
  case 'toggle': {
1063
1342
  const before = sessionA11y(session);
1064
1343
  const wait = await sendSetChecked(session, field.label, { checked: field.checked, exact: field.exact, controlType: field.controlType }, field.timeoutMs);
1065
- return `Set ${field.controlType ?? 'checkbox/radio'} "${field.label}" to ${String(field.checked ?? true)}.\n${postActionSummary(session, before, wait, detail)}`;
1344
+ return {
1345
+ summary: `Set ${field.controlType ?? 'checkbox/radio'} "${field.label}" to ${String(field.checked ?? true)}.\n${postActionSummary(session, before, wait, detail)}`,
1346
+ compact: {
1347
+ label: field.label,
1348
+ checked: field.checked ?? true,
1349
+ ...(field.controlType ? { controlType: field.controlType } : {}),
1350
+ ...waitStatusPayload(wait),
1351
+ },
1352
+ };
1066
1353
  }
1067
1354
  case 'file': {
1068
1355
  const before = sessionA11y(session);
1069
1356
  const wait = await sendFileUpload(session, field.paths, { fieldLabel: field.fieldLabel, exact: field.exact }, field.timeoutMs ?? 8_000);
1070
1357
  const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
1071
- return [
1072
- `Uploaded ${field.paths.length} file(s) to "${field.fieldLabel}".`,
1073
- fieldSummary,
1074
- postActionSummary(session, before, wait, detail),
1075
- ].filter(Boolean).join('\n');
1358
+ return {
1359
+ summary: [
1360
+ `Uploaded ${field.paths.length} file(s) to "${field.fieldLabel}".`,
1361
+ fieldSummary,
1362
+ postActionSummary(session, before, wait, detail),
1363
+ ].filter(Boolean).join('\n'),
1364
+ compact: {
1365
+ fieldLabel: field.fieldLabel,
1366
+ fileCount: field.paths.length,
1367
+ ...waitStatusPayload(wait),
1368
+ readback: fieldStatePayload(session, field.fieldLabel),
1369
+ },
1370
+ };
1076
1371
  }
1077
1372
  }
1078
1373
  }
@@ -1092,7 +1387,96 @@ function textMatches(haystack, needle) {
1092
1387
  return false;
1093
1388
  return haystack.toLowerCase().includes(needle.toLowerCase());
1094
1389
  }
1095
- function nodeMatchesFilter(node, filter) {
1390
+ function sortA11yNodes(nodes) {
1391
+ return [...nodes].sort((a, b) => {
1392
+ if (a.bounds.y !== b.bounds.y)
1393
+ return a.bounds.y - b.bounds.y;
1394
+ if (a.bounds.x !== b.bounds.x)
1395
+ return a.bounds.x - b.bounds.x;
1396
+ return a.path.length - b.path.length;
1397
+ });
1398
+ }
1399
+ function clamp(value, min, max) {
1400
+ return Math.min(Math.max(value, min), max);
1401
+ }
1402
+ function pathStartsWith(path, prefix) {
1403
+ if (prefix.length > path.length)
1404
+ return false;
1405
+ for (let index = 0; index < prefix.length; index++) {
1406
+ if (path[index] !== prefix[index])
1407
+ return false;
1408
+ }
1409
+ return true;
1410
+ }
1411
+ function namedAncestors(root, path) {
1412
+ const out = [];
1413
+ let current = root;
1414
+ for (const index of path) {
1415
+ out.push(current);
1416
+ if (!current.children[index])
1417
+ break;
1418
+ current = current.children[index];
1419
+ }
1420
+ return out;
1421
+ }
1422
+ function collectDescendants(node, predicate) {
1423
+ const out = [];
1424
+ function walk(current) {
1425
+ for (const child of current.children) {
1426
+ if (predicate(child))
1427
+ out.push(child);
1428
+ walk(child);
1429
+ }
1430
+ }
1431
+ walk(node);
1432
+ return out;
1433
+ }
1434
+ function promptContext(root, node) {
1435
+ const ancestors = namedAncestors(root, node.path);
1436
+ const normalizedName = (node.name ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
1437
+ for (let index = ancestors.length - 1; index >= 0; index--) {
1438
+ const ancestor = ancestors[index];
1439
+ const grouped = collectDescendants(ancestor, candidate => candidate.role === 'button' || candidate.role === 'radio' || candidate.role === 'checkbox').length >= 2;
1440
+ if (!grouped && ancestor.role !== 'group' && ancestor.role !== 'form' && ancestor.role !== 'dialog')
1441
+ continue;
1442
+ const best = collectDescendants(ancestor, candidate => (candidate.role === 'heading' || candidate.role === 'text') &&
1443
+ !!truncateInlineText(candidate.name, 120) &&
1444
+ !pathStartsWith(candidate.path, node.path))
1445
+ .filter(candidate => candidate.bounds.y <= node.bounds.y + 8)
1446
+ .map(candidate => {
1447
+ const text = truncateInlineText(candidate.name, 120);
1448
+ if (!text)
1449
+ return null;
1450
+ if (text.toLowerCase() === normalizedName)
1451
+ return null;
1452
+ const dy = Math.max(0, node.bounds.y - candidate.bounds.y);
1453
+ const dx = Math.abs(node.bounds.x - candidate.bounds.x);
1454
+ const headingBonus = candidate.role === 'heading' ? -32 : 0;
1455
+ return { text, score: dy * 4 + dx + headingBonus };
1456
+ })
1457
+ .filter((candidate) => !!candidate)
1458
+ .sort((a, b) => a.score - b.score)[0];
1459
+ if (best?.text)
1460
+ return best.text;
1461
+ }
1462
+ return undefined;
1463
+ }
1464
+ function sectionContext(root, node) {
1465
+ const ancestors = namedAncestors(root, node.path);
1466
+ for (let index = ancestors.length - 1; index >= 0; index--) {
1467
+ const ancestor = ancestors[index];
1468
+ if (ancestor.role === 'form' || ancestor.role === 'dialog' || ancestor.role === 'main' || ancestor.role === 'navigation' || ancestor.role === 'region') {
1469
+ const name = truncateInlineText(ancestor.name, 80);
1470
+ if (name)
1471
+ return name;
1472
+ }
1473
+ }
1474
+ return undefined;
1475
+ }
1476
+ function nodeContextText(root, node) {
1477
+ return [promptContext(root, node), sectionContext(root, node)].filter(Boolean).join(' | ') || undefined;
1478
+ }
1479
+ function nodeMatchesFilter(node, filter, contextText) {
1096
1480
  if (filter.id && nodeIdForPath(node.path) !== filter.id)
1097
1481
  return false;
1098
1482
  if (filter.role && node.role !== filter.role)
@@ -1104,6 +1488,8 @@ function nodeMatchesFilter(node, filter) {
1104
1488
  if (filter.text &&
1105
1489
  !textMatches(`${node.name ?? ''} ${node.value ?? ''} ${node.validation?.error ?? ''} ${node.validation?.description ?? ''}`.trim(), filter.text))
1106
1490
  return false;
1491
+ if (!textMatches(contextText, filter.contextText))
1492
+ return false;
1107
1493
  if (filter.checked !== undefined && node.state?.checked !== filter.checked)
1108
1494
  return false;
1109
1495
  if (filter.disabled !== undefined && (node.state?.disabled ?? false) !== filter.disabled)
@@ -1125,7 +1511,8 @@ function nodeMatchesFilter(node, filter) {
1125
1511
  export function findNodes(node, filter) {
1126
1512
  const matches = [];
1127
1513
  function walk(n) {
1128
- if (nodeMatchesFilter(n, filter) && hasNodeFilter(filter))
1514
+ const contextText = filter.contextText ? nodeContextText(node, n) : undefined;
1515
+ if (nodeMatchesFilter(n, filter, contextText) && hasNodeFilter(filter))
1129
1516
  matches.push(n);
1130
1517
  for (const child of n.children)
1131
1518
  walk(child);
@@ -1134,32 +1521,23 @@ export function findNodes(node, filter) {
1134
1521
  return matches;
1135
1522
  }
1136
1523
  function summarizeFieldLabelState(session, fieldLabel) {
1137
- const a11y = sessionA11y(session);
1138
- if (!a11y)
1139
- return undefined;
1140
- const matches = findNodes(a11y, {
1141
- name: fieldLabel,
1142
- role: 'combobox',
1143
- });
1144
- if (matches.length === 0) {
1145
- matches.push(...findNodes(a11y, { name: fieldLabel, role: 'textbox' }));
1146
- }
1147
- if (matches.length === 0) {
1148
- matches.push(...findNodes(a11y, { name: fieldLabel, role: 'button' }));
1149
- }
1150
- const match = matches[0];
1151
- if (!match)
1524
+ const payload = fieldStatePayload(session, fieldLabel);
1525
+ if (!payload)
1152
1526
  return undefined;
1153
1527
  const parts = [`Field "${fieldLabel}"`];
1154
- if (match.value)
1155
- parts.push(`value=${JSON.stringify(match.value)}`);
1156
- if (match.state && Object.keys(match.state).length > 0)
1157
- parts.push(`state=${JSON.stringify(match.state)}`);
1158
- if (match.validation?.error)
1159
- parts.push(`error=${JSON.stringify(match.validation.error)}`);
1528
+ if (payload.role)
1529
+ parts.push(`role=${String(payload.role)}`);
1530
+ if (payload.value)
1531
+ parts.push(`value=${JSON.stringify(payload.value)}`);
1532
+ if (payload.valueLength)
1533
+ parts.push(`valueLength=${String(payload.valueLength)}`);
1534
+ if (payload.state)
1535
+ parts.push(`state=${JSON.stringify(payload.state)}`);
1536
+ if (payload.error)
1537
+ parts.push(`error=${JSON.stringify(payload.error)}`);
1160
1538
  return parts.join(' ');
1161
1539
  }
1162
- function formatNode(node, viewport) {
1540
+ function formatNode(node, root, viewport) {
1163
1541
  const visibleLeft = Math.max(0, node.bounds.x);
1164
1542
  const visibleTop = Math.max(0, node.bounds.y);
1165
1543
  const visibleRight = Math.min(viewport.width, node.bounds.x + node.bounds.width);
@@ -1177,11 +1555,14 @@ function formatNode(node, viewport) {
1177
1555
  : Math.round(Math.min(Math.max(node.bounds.y + node.bounds.height / 2, 0), viewport.height));
1178
1556
  const revealDeltaX = Math.round(node.bounds.x + node.bounds.width / 2 - viewport.width / 2);
1179
1557
  const revealDeltaY = Math.round(node.bounds.y + node.bounds.height / 2 - viewport.height / 2);
1558
+ const prompt = promptContext(root, node);
1559
+ const section = sectionContext(root, node);
1180
1560
  return {
1181
1561
  id: nodeIdForPath(node.path),
1182
1562
  role: node.role,
1183
1563
  name: node.name,
1184
1564
  ...(node.value ? { value: node.value } : {}),
1565
+ ...(prompt || section ? { context: { ...(prompt ? { prompt } : {}), ...(section ? { section } : {}) } } : {}),
1185
1566
  bounds: node.bounds,
1186
1567
  visibleBounds: {
1187
1568
  x: visibleLeft,