@geometra/mcp 1.19.11 → 1.19.13

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
@@ -1,7 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
3
  import { formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget } from './connect-utils.js';
4
- import { connect, connectThroughProxy, disconnect, getSession, sendClick, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, buildA11yTree, buildCompactUiIndex, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
4
+ import { connect, connectThroughProxy, disconnect, getSession, sendClick, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, buildA11yTree, buildCompactUiIndex, buildPageModel, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
5
5
  function checkedStateInput() {
6
6
  return z
7
7
  .union([z.boolean(), z.literal('mixed')])
@@ -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'),
@@ -65,6 +66,12 @@ const fillFieldSchema = z.discriminatedUnion('kind', [
65
66
  timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
66
67
  }),
67
68
  ]);
69
+ const formValueSchema = z.union([
70
+ z.string(),
71
+ z.boolean(),
72
+ z.array(z.string()).min(1),
73
+ ]);
74
+ const formValuesRecordSchema = z.record(z.string(), formValueSchema);
68
75
  const batchActionSchema = z.discriminatedUnion('type', [
69
76
  z.object({
70
77
  type: z.literal('click'),
@@ -145,7 +152,7 @@ const batchActionSchema = z.discriminatedUnion('type', [
145
152
  }),
146
153
  ]);
147
154
  export function createServer() {
148
- const server = new McpServer({ name: 'geometra', version: '1.19.11' }, { capabilities: { tools: {} } });
155
+ const server = new McpServer({ name: 'geometra', version: '1.19.13' }, { capabilities: { tools: {} } });
149
156
  // ── connect ──────────────────────────────────────────────────
150
157
  server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
151
158
 
@@ -183,6 +190,7 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
183
190
  .nonnegative()
184
191
  .optional()
185
192
  .describe('Playwright slowMo (ms) on spawned proxy for easier visual following.'),
193
+ detail: detailInput(),
186
194
  }, async (input) => {
187
195
  const normalized = normalizeConnectTarget({ url: input.url, pageUrl: input.pageUrl });
188
196
  if (!normalized.ok)
@@ -198,13 +206,20 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
198
206
  height: input.height,
199
207
  slowMo: input.slowMo,
200
208
  });
201
- const summary = compactSessionSummary(session);
202
- const inferred = target.autoCoercedFromUrl ? ' inferred from url input' : '';
203
- return ok(`Started geometra-proxy and connected at ${session.url} (page: ${target.pageUrl}${inferred}). UI state:\n${summary}`);
209
+ return ok(JSON.stringify(connectPayload(session, {
210
+ transport: 'proxy',
211
+ requestedPageUrl: target.pageUrl,
212
+ autoCoercedFromUrl: target.autoCoercedFromUrl,
213
+ detail: input.detail,
214
+ }), null, input.detail === 'verbose' ? 2 : undefined));
204
215
  }
205
216
  const session = await connect(target.wsUrl);
206
- const summary = compactSessionSummary(session);
207
- return ok(`Connected to ${target.wsUrl}. UI state:\n${summary}`);
217
+ return ok(JSON.stringify(connectPayload(session, {
218
+ transport: 'ws',
219
+ requestedWsUrl: target.wsUrl,
220
+ autoCoercedFromUrl: false,
221
+ detail: input.detail,
222
+ }), null, input.detail === 'verbose' ? 2 : undefined));
208
223
  }
209
224
  catch (e) {
210
225
  return err(`Failed to connect: ${formatConnectFailureMessage(e, target)}`);
@@ -213,7 +228,7 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
213
228
  // ── query ────────────────────────────────────────────────────
214
229
  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
230
 
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 }) => {
231
+ 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
232
  const session = getSession();
218
233
  if (!session?.tree || !session?.layout)
219
234
  return err('Not connected. Call geometra_connect first.');
@@ -223,6 +238,7 @@ This is the Geometra equivalent of Playwright's locator — but instant, structu
223
238
  role,
224
239
  name,
225
240
  text,
241
+ contextText,
226
242
  value,
227
243
  checked,
228
244
  disabled,
@@ -234,12 +250,12 @@ This is the Geometra equivalent of Playwright's locator — but instant, structu
234
250
  busy,
235
251
  };
236
252
  if (!hasNodeFilter(filter))
237
- return err('Provide at least one query filter (id, role, name, text, value, or state)');
253
+ return err('Provide at least one query filter (id, role, name, text, contextText, value, or state)');
238
254
  const matches = findNodes(a11y, filter);
239
255
  if (matches.length === 0) {
240
256
  return ok(`No elements found matching ${JSON.stringify(filter)}`);
241
257
  }
242
- const result = matches.map(node => formatNode(node, a11y.bounds));
258
+ const result = sortA11yNodes(matches).map(node => formatNode(node, a11y, a11y.bounds));
243
259
  return ok(JSON.stringify(result, null, 2));
244
260
  });
245
261
  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 +271,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
255
271
  .optional()
256
272
  .default(10_000)
257
273
  .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 }) => {
274
+ }, async ({ id, role, name, text, contextText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, present, timeoutMs }) => {
259
275
  const session = getSession();
260
276
  if (!session?.tree || !session?.layout)
261
277
  return err('Not connected. Call geometra_connect first.');
@@ -264,6 +280,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
264
280
  role,
265
281
  name,
266
282
  text,
283
+ contextText,
267
284
  value,
268
285
  checked,
269
286
  disabled,
@@ -275,7 +292,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
275
292
  busy,
276
293
  };
277
294
  if (!hasNodeFilter(filter))
278
- return err('Provide at least one wait filter (id, role, name, text, value, or state)');
295
+ return err('Provide at least one wait filter (id, role, name, text, contextText, value, or state)');
279
296
  const matchesCondition = () => {
280
297
  if (!session.tree || !session.layout)
281
298
  return false;
@@ -296,7 +313,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
296
313
  if (!after)
297
314
  return ok(`Condition satisfied after ${elapsedMs}ms for ${JSON.stringify(filter)}.`);
298
315
  const matches = findNodes(after, filter);
299
- const result = matches.slice(0, 8).map(node => formatNode(node, after.bounds));
316
+ const result = sortA11yNodes(matches).slice(0, 8).map(node => formatNode(node, after, after.bounds));
300
317
  return ok(JSON.stringify(result, null, 2));
301
318
  });
302
319
  server.tool('geometra_fill_fields', `Fill several labeled form fields in one MCP call. This is the preferred high-level primitive for long forms.
@@ -357,6 +374,85 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
357
374
  }
358
375
  return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
359
376
  });
377
+ server.tool('geometra_fill_form', `Fill a form from a compact values object instead of expanding sections first. This is the lowest-token happy path for standard application flows.
378
+
379
+ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most stable matching, or \`valuesByLabel\` when labels are unique enough. MCP resolves the form schema, executes the semantic field operations server-side, and returns one consolidated result.`, {
380
+ formId: z.string().optional().describe('Optional form id from geometra_form_schema or geometra_page_model'),
381
+ valuesById: formValuesRecordSchema.optional().describe('Form values keyed by stable field id from geometra_form_schema'),
382
+ valuesByLabel: formValuesRecordSchema.optional().describe('Form values keyed by schema field label'),
383
+ stopOnError: z.boolean().optional().default(true).describe('Stop at the first failing field (default true)'),
384
+ failOnInvalid: z
385
+ .boolean()
386
+ .optional()
387
+ .default(false)
388
+ .describe('Return an error if invalid fields remain after filling'),
389
+ includeSteps: z
390
+ .boolean()
391
+ .optional()
392
+ .default(false)
393
+ .describe('Include per-field step results in the JSON payload (default false for the smallest response)'),
394
+ detail: detailInput(),
395
+ }, async ({ formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, detail }) => {
396
+ const session = getSession();
397
+ if (!session)
398
+ return err('Not connected. Call geometra_connect first.');
399
+ const entryCount = Object.keys(valuesById ?? {}).length + Object.keys(valuesByLabel ?? {}).length;
400
+ if (entryCount === 0) {
401
+ return err('Provide at least one value in valuesById or valuesByLabel');
402
+ }
403
+ const afterConnect = sessionA11y(session);
404
+ if (!afterConnect)
405
+ return err('No UI tree available for form filling');
406
+ const schemas = buildFormSchemas(afterConnect);
407
+ if (schemas.length === 0)
408
+ return err('No forms found in the current UI');
409
+ const resolution = resolveTargetFormSchema(schemas, { formId, valuesById, valuesByLabel });
410
+ if (!resolution.ok)
411
+ return err(resolution.error);
412
+ const schema = resolution.schema;
413
+ const planned = planFormFill(schema, { valuesById, valuesByLabel });
414
+ if (!planned.ok)
415
+ return err(planned.error);
416
+ const steps = [];
417
+ let stoppedAt;
418
+ for (let index = 0; index < planned.fields.length; index++) {
419
+ const field = planned.fields[index];
420
+ try {
421
+ const result = await executeFillField(session, field, detail);
422
+ steps.push(detail === 'verbose'
423
+ ? { index, kind: field.kind, ok: true, summary: result.summary }
424
+ : { index, kind: field.kind, ok: true, ...result.compact });
425
+ }
426
+ catch (e) {
427
+ const message = e instanceof Error ? e.message : String(e);
428
+ steps.push({ index, kind: field.kind, ok: false, error: message });
429
+ if (stopOnError) {
430
+ stoppedAt = index;
431
+ break;
432
+ }
433
+ }
434
+ }
435
+ const after = sessionA11y(session);
436
+ const signals = after ? collectSessionSignals(after) : undefined;
437
+ const invalidRemaining = signals?.invalidFields.length ?? 0;
438
+ const successCount = steps.filter(step => step.ok === true).length;
439
+ const errorCount = steps.length - successCount;
440
+ const payload = {
441
+ completed: stoppedAt === undefined && steps.length === planned.fields.length,
442
+ formId: schema.formId,
443
+ requestedValueCount: entryCount,
444
+ fieldCount: planned.fields.length,
445
+ successCount,
446
+ errorCount,
447
+ ...(includeSteps ? { steps } : {}),
448
+ ...(stoppedAt !== undefined ? { stoppedAt } : {}),
449
+ ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
450
+ };
451
+ if (failOnInvalid && invalidRemaining > 0) {
452
+ return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
453
+ }
454
+ return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
455
+ });
360
456
  server.tool('geometra_run_actions', `Execute several Geometra actions in one MCP round trip and return one consolidated result. This is the preferred path for long, multi-step form fills where one-tool-per-field would otherwise create too much chatter.
361
457
 
362
458
  Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_listbox_option\`, \`select_option\`, \`set_checked\`, \`wheel\`, \`wait_for\`, and \`fill_fields\`.`, {
@@ -433,18 +529,47 @@ Use this first on normal HTML pages when you want to understand the page shape w
433
529
  const model = buildPageModel(a11y, { maxPrimaryActions, maxSectionsPerKind });
434
530
  return ok(JSON.stringify(model));
435
531
  });
532
+ server.tool('geometra_form_schema', `Get a compact, fill-oriented schema for forms on the page. This is the preferred discovery step before geometra_fill_form.
533
+
534
+ Unlike geometra_expand_section, this collapses repeated radio/button groups into single logical fields, keeps output compact, and omits layout-heavy detail by default.`, {
535
+ formId: z.string().optional().describe('Optional form id from geometra_page_model. If omitted, returns every form schema on the page.'),
536
+ maxFields: z.number().int().min(1).max(120).optional().default(80).describe('Cap returned fields per form'),
537
+ onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields'),
538
+ onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields'),
539
+ }, async ({ formId, maxFields, onlyRequiredFields, onlyInvalidFields }) => {
540
+ const session = getSession();
541
+ if (!session?.tree || !session?.layout)
542
+ return err('Not connected. Call geometra_connect first.');
543
+ const a11y = buildA11yTree(session.tree, session.layout);
544
+ const forms = buildFormSchemas(a11y, {
545
+ formId,
546
+ maxFields,
547
+ onlyRequiredFields,
548
+ onlyInvalidFields,
549
+ });
550
+ if (forms.length === 0) {
551
+ return err(formId ? `No form schema found for id ${formId}` : 'No forms found in the current UI');
552
+ }
553
+ return ok(JSON.stringify({ forms }));
554
+ });
436
555
  server.tool('geometra_expand_section', `Expand one section from geometra_page_model by stable id. Returns richer on-demand details such as headings, fields, actions, nested lists, list items, and text preview.
437
556
 
438
557
  Use this after geometra_page_model when you know which form/dialog/list/landmark you want to inspect more closely. Per-item bounds are omitted by default to save tokens; set includeBounds=true if you need them immediately.`, {
439
558
  id: z.string().describe('Section id from geometra_page_model, e.g. fm:1.0 or ls:2.1'),
440
559
  maxHeadings: z.number().int().min(1).max(20).optional().default(6).describe('Cap heading rows'),
441
560
  maxFields: z.number().int().min(1).max(40).optional().default(18).describe('Cap field rows'),
561
+ fieldOffset: z.number().int().min(0).optional().default(0).describe('Field row offset for long forms'),
562
+ onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields'),
563
+ onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields'),
442
564
  maxActions: z.number().int().min(1).max(30).optional().default(12).describe('Cap action rows'),
565
+ actionOffset: z.number().int().min(0).optional().default(0).describe('Action row offset'),
443
566
  maxLists: z.number().int().min(0).max(20).optional().default(8).describe('Cap nested lists'),
567
+ listOffset: z.number().int().min(0).optional().default(0).describe('Nested-list offset'),
444
568
  maxItems: z.number().int().min(0).max(50).optional().default(20).describe('Cap list items'),
569
+ itemOffset: z.number().int().min(0).optional().default(0).describe('List-item offset'),
445
570
  maxTextPreview: z.number().int().min(0).max(20).optional().default(6).describe('Cap text preview lines'),
446
571
  includeBounds: z.boolean().optional().default(false).describe('Include bounds for fields/actions/headings/items'),
447
- }, async ({ id, maxHeadings, maxFields, maxActions, maxLists, maxItems, maxTextPreview, includeBounds }) => {
572
+ }, async ({ id, maxHeadings, maxFields, fieldOffset, onlyRequiredFields, onlyInvalidFields, maxActions, actionOffset, maxLists, listOffset, maxItems, itemOffset, maxTextPreview, includeBounds, }) => {
448
573
  const session = getSession();
449
574
  if (!session?.tree || !session?.layout)
450
575
  return err('Not connected. Call geometra_connect first.');
@@ -452,9 +577,15 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
452
577
  const detail = expandPageSection(a11y, id, {
453
578
  maxHeadings,
454
579
  maxFields,
580
+ fieldOffset,
581
+ onlyRequiredFields,
582
+ onlyInvalidFields,
455
583
  maxActions,
584
+ actionOffset,
456
585
  maxLists,
586
+ listOffset,
457
587
  maxItems,
588
+ itemOffset,
458
589
  maxTextPreview,
459
590
  includeBounds,
460
591
  });
@@ -462,6 +593,90 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
462
593
  return err(`No expandable section found for id ${id}`);
463
594
  return ok(JSON.stringify(detail));
464
595
  });
596
+ 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.
597
+
598
+ Use the same filters as geometra_query, plus an optional match index when repeated controls share the same visible label.`, {
599
+ ...nodeFilterShape(),
600
+ index: z.number().int().min(0).optional().default(0).describe('Which matching node to reveal after sorting top-to-bottom'),
601
+ fullyVisible: z.boolean().optional().default(true).describe('Require the target to become fully visible (default true)'),
602
+ maxSteps: z.number().int().min(1).max(12).optional().default(6).describe('Maximum reveal attempts before returning an error'),
603
+ timeoutMs: z
604
+ .number()
605
+ .int()
606
+ .min(50)
607
+ .max(60_000)
608
+ .optional()
609
+ .default(2_500)
610
+ .describe('Per-scroll wait timeout (default 2500ms)'),
611
+ }, async ({ id, role, name, text, contextText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, index, fullyVisible, maxSteps, timeoutMs }) => {
612
+ const session = getSession();
613
+ if (!session)
614
+ return err('Not connected. Call geometra_connect first.');
615
+ const matchIndex = index ?? 0;
616
+ const requireFullyVisible = fullyVisible ?? true;
617
+ const revealSteps = maxSteps ?? 6;
618
+ const waitTimeout = timeoutMs ?? 2_500;
619
+ const filter = {
620
+ id,
621
+ role,
622
+ name,
623
+ text,
624
+ contextText,
625
+ value,
626
+ checked,
627
+ disabled,
628
+ focused,
629
+ selected,
630
+ expanded,
631
+ invalid,
632
+ required,
633
+ busy,
634
+ };
635
+ if (!hasNodeFilter(filter))
636
+ return err('Provide at least one reveal filter (id, role, name, text, contextText, value, or state)');
637
+ let attempts = 0;
638
+ while (attempts <= revealSteps) {
639
+ const a11y = sessionA11y(session);
640
+ if (!a11y)
641
+ return err('No UI tree available to reveal from');
642
+ const matches = sortA11yNodes(findNodes(a11y, filter));
643
+ if (matches.length === 0) {
644
+ return err(`No elements found matching ${JSON.stringify(filter)}`);
645
+ }
646
+ if (matchIndex >= matches.length) {
647
+ return err(`Requested reveal index ${matchIndex} but only ${matches.length} matching element(s) were found`);
648
+ }
649
+ const target = matches[matchIndex];
650
+ const formatted = formatNode(target, a11y, a11y.bounds);
651
+ const visible = requireFullyVisible ? formatted.visibility.fullyVisible : formatted.visibility.intersectsViewport;
652
+ if (visible) {
653
+ return ok(JSON.stringify({
654
+ revealed: true,
655
+ attempts,
656
+ target: formatted,
657
+ }, null, 2));
658
+ }
659
+ if (attempts === revealSteps) {
660
+ return err(JSON.stringify({
661
+ revealed: false,
662
+ attempts,
663
+ target: formatted,
664
+ }, null, 2));
665
+ }
666
+ const deltaX = clamp(formatted.scrollHint.revealDeltaX, -Math.round(a11y.bounds.width * 0.75), Math.round(a11y.bounds.width * 0.75));
667
+ let deltaY = clamp(formatted.scrollHint.revealDeltaY, -Math.round(a11y.bounds.height * 0.85), Math.round(a11y.bounds.height * 0.85));
668
+ if (deltaY === 0 && !formatted.visibility.fullyVisible) {
669
+ deltaY = formatted.visibility.offscreenAbove ? -Math.round(a11y.bounds.height * 0.4) : Math.round(a11y.bounds.height * 0.4);
670
+ }
671
+ await sendWheel(session, deltaY, {
672
+ deltaX,
673
+ x: formatted.center.x,
674
+ y: formatted.center.y,
675
+ }, waitTimeout);
676
+ attempts++;
677
+ }
678
+ return err(`Failed to reveal ${JSON.stringify(filter)}`);
679
+ });
465
680
  // ── click ────────────────────────────────────────────────────
466
681
  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.
467
682
 
@@ -764,6 +979,18 @@ function compactSessionSummary(session) {
764
979
  return 'No UI update received';
765
980
  return sessionOverviewFromA11y(a11y);
766
981
  }
982
+ function connectPayload(session, opts) {
983
+ const a11y = sessionA11y(session);
984
+ return {
985
+ connected: true,
986
+ transport: opts.transport,
987
+ wsUrl: session.url,
988
+ ...(a11y?.meta?.pageUrl || opts.requestedPageUrl ? { pageUrl: a11y?.meta?.pageUrl ?? opts.requestedPageUrl } : {}),
989
+ ...(opts.requestedWsUrl ? { requestedWsUrl: opts.requestedWsUrl } : {}),
990
+ ...(opts.autoCoercedFromUrl ? { autoCoercedFromUrl: true } : {}),
991
+ ...(opts.detail === 'verbose' && a11y ? { currentUi: sessionOverviewFromA11y(a11y) } : {}),
992
+ };
993
+ }
767
994
  function sessionA11y(session) {
768
995
  if (!session.tree || !session.layout)
769
996
  return null;
@@ -973,6 +1200,120 @@ function waitStatusPayload(wait) {
973
1200
  function compactFilterPayload(filter) {
974
1201
  return Object.fromEntries(Object.entries(filter).filter(([, value]) => value !== undefined));
975
1202
  }
1203
+ function normalizeLookupKey(value) {
1204
+ return value.replace(/\s+/g, ' ').trim().toLowerCase();
1205
+ }
1206
+ function resolveTargetFormSchema(schemas, opts) {
1207
+ if (opts.formId) {
1208
+ const matched = schemas.find(schema => schema.formId === opts.formId);
1209
+ return matched
1210
+ ? { ok: true, schema: matched }
1211
+ : { ok: false, error: `No form schema found for id ${opts.formId}` };
1212
+ }
1213
+ if (schemas.length === 1)
1214
+ return { ok: true, schema: schemas[0] };
1215
+ const idKeys = Object.keys(opts.valuesById ?? {});
1216
+ const labelKeys = Object.keys(opts.valuesByLabel ?? {}).map(normalizeLookupKey);
1217
+ const matches = schemas.filter(schema => {
1218
+ const ids = new Set(schema.fields.map(field => field.id));
1219
+ const labels = new Set(schema.fields.map(field => normalizeLookupKey(field.label)));
1220
+ return idKeys.every(id => ids.has(id)) && labelKeys.every(label => labels.has(label));
1221
+ });
1222
+ if (matches.length === 1)
1223
+ return { ok: true, schema: matches[0] };
1224
+ if (matches.length === 0) {
1225
+ return {
1226
+ ok: false,
1227
+ error: 'Could not infer which form to fill from the provided field ids/labels. Pass formId from geometra_form_schema.',
1228
+ };
1229
+ }
1230
+ return {
1231
+ ok: false,
1232
+ error: 'Multiple forms match the provided field ids/labels. Pass formId from geometra_form_schema.',
1233
+ };
1234
+ }
1235
+ function coerceChoiceValue(field, value) {
1236
+ if (typeof value === 'string')
1237
+ return value;
1238
+ if (typeof value !== 'boolean')
1239
+ return null;
1240
+ const desired = value ? 'yes' : 'no';
1241
+ const option = field.options?.find(option => normalizeLookupKey(option) === desired);
1242
+ return option ?? (value ? 'Yes' : 'No');
1243
+ }
1244
+ function plannedFillInputsForField(field, value) {
1245
+ if (field.kind === 'text') {
1246
+ if (typeof value !== 'string')
1247
+ return { error: `Field "${field.label}" expects a string value` };
1248
+ return [{ kind: 'text', fieldLabel: field.label, value }];
1249
+ }
1250
+ if (field.kind === 'choice') {
1251
+ const coerced = coerceChoiceValue(field, value);
1252
+ if (!coerced)
1253
+ return { error: `Field "${field.label}" expects a string value` };
1254
+ return [{ kind: 'choice', fieldLabel: field.label, value: coerced }];
1255
+ }
1256
+ if (field.kind === 'toggle') {
1257
+ if (typeof value !== 'boolean')
1258
+ return { error: `Field "${field.label}" expects a boolean value` };
1259
+ return [{ kind: 'toggle', label: field.label, checked: value, controlType: field.controlType }];
1260
+ }
1261
+ const selected = Array.isArray(value) ? value : typeof value === 'string' ? [value] : null;
1262
+ if (!selected || selected.length === 0)
1263
+ return { error: `Field "${field.label}" expects a string array value` };
1264
+ if (!field.options || field.options.length === 0) {
1265
+ return { error: `Field "${field.label}" does not expose checkbox options; use geometra_fill_fields for this field` };
1266
+ }
1267
+ const selectedKeys = new Set(selected.map(normalizeLookupKey));
1268
+ return field.options.map(option => ({
1269
+ kind: 'toggle',
1270
+ label: option,
1271
+ checked: selectedKeys.has(normalizeLookupKey(option)),
1272
+ controlType: 'checkbox',
1273
+ }));
1274
+ }
1275
+ function planFormFill(schema, opts) {
1276
+ const fieldById = new Map(schema.fields.map(field => [field.id, field]));
1277
+ const fieldsByLabel = new Map();
1278
+ for (const field of schema.fields) {
1279
+ const key = normalizeLookupKey(field.label);
1280
+ const existing = fieldsByLabel.get(key);
1281
+ if (existing)
1282
+ existing.push(field);
1283
+ else
1284
+ fieldsByLabel.set(key, [field]);
1285
+ }
1286
+ const planned = [];
1287
+ const seenFieldIds = new Set();
1288
+ for (const [fieldId, value] of Object.entries(opts.valuesById ?? {})) {
1289
+ const field = fieldById.get(fieldId);
1290
+ if (!field)
1291
+ return { ok: false, error: `Unknown form field id ${fieldId}. Refresh geometra_form_schema and try again.` };
1292
+ const next = plannedFillInputsForField(field, value);
1293
+ if ('error' in next)
1294
+ return { ok: false, error: next.error };
1295
+ planned.push(...next);
1296
+ seenFieldIds.add(field.id);
1297
+ }
1298
+ for (const [label, value] of Object.entries(opts.valuesByLabel ?? {})) {
1299
+ const matches = fieldsByLabel.get(normalizeLookupKey(label)) ?? [];
1300
+ if (matches.length === 0)
1301
+ return { ok: false, error: `Unknown form field label "${label}". Refresh geometra_form_schema and try again.` };
1302
+ if (matches.length > 1) {
1303
+ return { ok: false, error: `Label "${label}" is ambiguous in form ${schema.formId}. Use valuesById for this field.` };
1304
+ }
1305
+ const field = matches[0];
1306
+ if (seenFieldIds.has(field.id)) {
1307
+ return { ok: false, error: `Field "${label}" was provided in both valuesById and valuesByLabel` };
1308
+ }
1309
+ const next = plannedFillInputsForField(field, value);
1310
+ if ('error' in next)
1311
+ return { ok: false, error: next.error };
1312
+ planned.push(...next);
1313
+ seenFieldIds.add(field.id);
1314
+ }
1315
+ return { ok: true, fields: planned };
1316
+ }
976
1317
  async function executeBatchAction(session, action, detail, includeSteps) {
977
1318
  switch (action.type) {
978
1319
  case 'click': {
@@ -1111,6 +1452,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
1111
1452
  role: action.role,
1112
1453
  name: action.name,
1113
1454
  text: action.text,
1455
+ contextText: action.contextText,
1114
1456
  value: action.value,
1115
1457
  checked: action.checked,
1116
1458
  disabled: action.disabled,
@@ -1162,7 +1504,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
1162
1504
  const matches = findNodes(after, filter);
1163
1505
  if (detail === 'verbose') {
1164
1506
  return {
1165
- summary: JSON.stringify(matches.slice(0, 8).map(node => formatNode(node, after.bounds)), null, 2),
1507
+ summary: JSON.stringify(sortA11yNodes(matches).slice(0, 8).map(node => formatNode(node, after, after.bounds)), null, 2),
1166
1508
  compact: {
1167
1509
  present,
1168
1510
  elapsedMs,
@@ -1287,7 +1629,96 @@ function textMatches(haystack, needle) {
1287
1629
  return false;
1288
1630
  return haystack.toLowerCase().includes(needle.toLowerCase());
1289
1631
  }
1290
- function nodeMatchesFilter(node, filter) {
1632
+ function sortA11yNodes(nodes) {
1633
+ return [...nodes].sort((a, b) => {
1634
+ if (a.bounds.y !== b.bounds.y)
1635
+ return a.bounds.y - b.bounds.y;
1636
+ if (a.bounds.x !== b.bounds.x)
1637
+ return a.bounds.x - b.bounds.x;
1638
+ return a.path.length - b.path.length;
1639
+ });
1640
+ }
1641
+ function clamp(value, min, max) {
1642
+ return Math.min(Math.max(value, min), max);
1643
+ }
1644
+ function pathStartsWith(path, prefix) {
1645
+ if (prefix.length > path.length)
1646
+ return false;
1647
+ for (let index = 0; index < prefix.length; index++) {
1648
+ if (path[index] !== prefix[index])
1649
+ return false;
1650
+ }
1651
+ return true;
1652
+ }
1653
+ function namedAncestors(root, path) {
1654
+ const out = [];
1655
+ let current = root;
1656
+ for (const index of path) {
1657
+ out.push(current);
1658
+ if (!current.children[index])
1659
+ break;
1660
+ current = current.children[index];
1661
+ }
1662
+ return out;
1663
+ }
1664
+ function collectDescendants(node, predicate) {
1665
+ const out = [];
1666
+ function walk(current) {
1667
+ for (const child of current.children) {
1668
+ if (predicate(child))
1669
+ out.push(child);
1670
+ walk(child);
1671
+ }
1672
+ }
1673
+ walk(node);
1674
+ return out;
1675
+ }
1676
+ function promptContext(root, node) {
1677
+ const ancestors = namedAncestors(root, node.path);
1678
+ const normalizedName = (node.name ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
1679
+ for (let index = ancestors.length - 1; index >= 0; index--) {
1680
+ const ancestor = ancestors[index];
1681
+ const grouped = collectDescendants(ancestor, candidate => candidate.role === 'button' || candidate.role === 'radio' || candidate.role === 'checkbox').length >= 2;
1682
+ if (!grouped && ancestor.role !== 'group' && ancestor.role !== 'form' && ancestor.role !== 'dialog')
1683
+ continue;
1684
+ const best = collectDescendants(ancestor, candidate => (candidate.role === 'heading' || candidate.role === 'text') &&
1685
+ !!truncateInlineText(candidate.name, 120) &&
1686
+ !pathStartsWith(candidate.path, node.path))
1687
+ .filter(candidate => candidate.bounds.y <= node.bounds.y + 8)
1688
+ .map(candidate => {
1689
+ const text = truncateInlineText(candidate.name, 120);
1690
+ if (!text)
1691
+ return null;
1692
+ if (text.toLowerCase() === normalizedName)
1693
+ return null;
1694
+ const dy = Math.max(0, node.bounds.y - candidate.bounds.y);
1695
+ const dx = Math.abs(node.bounds.x - candidate.bounds.x);
1696
+ const headingBonus = candidate.role === 'heading' ? -32 : 0;
1697
+ return { text, score: dy * 4 + dx + headingBonus };
1698
+ })
1699
+ .filter((candidate) => !!candidate)
1700
+ .sort((a, b) => a.score - b.score)[0];
1701
+ if (best?.text)
1702
+ return best.text;
1703
+ }
1704
+ return undefined;
1705
+ }
1706
+ function sectionContext(root, node) {
1707
+ const ancestors = namedAncestors(root, node.path);
1708
+ for (let index = ancestors.length - 1; index >= 0; index--) {
1709
+ const ancestor = ancestors[index];
1710
+ if (ancestor.role === 'form' || ancestor.role === 'dialog' || ancestor.role === 'main' || ancestor.role === 'navigation' || ancestor.role === 'region') {
1711
+ const name = truncateInlineText(ancestor.name, 80);
1712
+ if (name)
1713
+ return name;
1714
+ }
1715
+ }
1716
+ return undefined;
1717
+ }
1718
+ function nodeContextText(root, node) {
1719
+ return [promptContext(root, node), sectionContext(root, node)].filter(Boolean).join(' | ') || undefined;
1720
+ }
1721
+ function nodeMatchesFilter(node, filter, contextText) {
1291
1722
  if (filter.id && nodeIdForPath(node.path) !== filter.id)
1292
1723
  return false;
1293
1724
  if (filter.role && node.role !== filter.role)
@@ -1299,6 +1730,8 @@ function nodeMatchesFilter(node, filter) {
1299
1730
  if (filter.text &&
1300
1731
  !textMatches(`${node.name ?? ''} ${node.value ?? ''} ${node.validation?.error ?? ''} ${node.validation?.description ?? ''}`.trim(), filter.text))
1301
1732
  return false;
1733
+ if (!textMatches(contextText, filter.contextText))
1734
+ return false;
1302
1735
  if (filter.checked !== undefined && node.state?.checked !== filter.checked)
1303
1736
  return false;
1304
1737
  if (filter.disabled !== undefined && (node.state?.disabled ?? false) !== filter.disabled)
@@ -1320,7 +1753,8 @@ function nodeMatchesFilter(node, filter) {
1320
1753
  export function findNodes(node, filter) {
1321
1754
  const matches = [];
1322
1755
  function walk(n) {
1323
- if (nodeMatchesFilter(n, filter) && hasNodeFilter(filter))
1756
+ const contextText = filter.contextText ? nodeContextText(node, n) : undefined;
1757
+ if (nodeMatchesFilter(n, filter, contextText) && hasNodeFilter(filter))
1324
1758
  matches.push(n);
1325
1759
  for (const child of n.children)
1326
1760
  walk(child);
@@ -1345,7 +1779,7 @@ function summarizeFieldLabelState(session, fieldLabel) {
1345
1779
  parts.push(`error=${JSON.stringify(payload.error)}`);
1346
1780
  return parts.join(' ');
1347
1781
  }
1348
- function formatNode(node, viewport) {
1782
+ function formatNode(node, root, viewport) {
1349
1783
  const visibleLeft = Math.max(0, node.bounds.x);
1350
1784
  const visibleTop = Math.max(0, node.bounds.y);
1351
1785
  const visibleRight = Math.min(viewport.width, node.bounds.x + node.bounds.width);
@@ -1363,11 +1797,14 @@ function formatNode(node, viewport) {
1363
1797
  : Math.round(Math.min(Math.max(node.bounds.y + node.bounds.height / 2, 0), viewport.height));
1364
1798
  const revealDeltaX = Math.round(node.bounds.x + node.bounds.width / 2 - viewport.width / 2);
1365
1799
  const revealDeltaY = Math.round(node.bounds.y + node.bounds.height / 2 - viewport.height / 2);
1800
+ const prompt = promptContext(root, node);
1801
+ const section = sectionContext(root, node);
1366
1802
  return {
1367
1803
  id: nodeIdForPath(node.path),
1368
1804
  role: node.role,
1369
1805
  name: node.name,
1370
1806
  ...(node.value ? { value: node.value } : {}),
1807
+ ...(prompt || section ? { context: { ...(prompt ? { prompt } : {}), ...(section ? { section } : {}) } } : {}),
1371
1808
  bounds: node.bounds,
1372
1809
  visibleBounds: {
1373
1810
  x: visibleLeft,