@nxisai/nxis 2.1.2 → 2.2.1

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.
@@ -0,0 +1,514 @@
1
+ /**
2
+ * Execute — fills a located form with the provided arguments and submits it.
3
+ *
4
+ * Dispatches `input` and `change` events to trigger framework reactivity,
5
+ * then submits the form via `requestSubmit()` (respects validation and
6
+ * submit event handlers).
7
+ *
8
+ * Returns a structured JSON result with a `success` flag and actionable
9
+ * `message` so the agent can self-correct on failure.
10
+ *
11
+ * @internal
12
+ */
13
+
14
+ import { NxisAction } from './contract';
15
+ import { locateForm } from './locate';
16
+ import { reconcileFields, ReconciledField } from './reconcile';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Result types
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /** Result of executing an action fill. */
23
+ interface FillResult {
24
+ /** Whether the fill was meaningful (at least one field filled, no fatal errors). */
25
+ success: boolean;
26
+ /** Whether the form was submitted after filling. */
27
+ submitted: boolean;
28
+ /** Human-readable summary for agent self-correction. */
29
+ message: string;
30
+ /** Fields that were successfully filled. */
31
+ filled: string[];
32
+ /** Fields that were skipped (with actionable reasons). */
33
+ skipped: Array<{ field: string; reason: string }>;
34
+ /** True when the action was extracted from a different page than the current one. */
35
+ stale?: boolean;
36
+ }
37
+
38
+ /** Internal fill outcome for a single field. */
39
+ type FieldFillOutcome =
40
+ | { ok: true }
41
+ | { ok: false; reason: string };
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Public API
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Execute a form fill action.
49
+ *
50
+ * Locates the form, reconciles fields, fills values, dispatches events,
51
+ * waits for framework reactivity to settle, then verifies values persisted.
52
+ *
53
+ * @param action - The NxisAction describing the form.
54
+ * @param args - The argument values to fill, keyed by valueName.
55
+ * @returns A JSON string describing the fill result.
56
+ */
57
+ export async function executeAction(
58
+ action: NxisAction,
59
+ args: Record<string, unknown>
60
+ ): Promise<string> {
61
+ // Step 0: Staleness — the action records the page it was extracted from.
62
+ // If the live page differs, the form may have moved or changed; surface a
63
+ // non-fatal warning so the agent can weigh the result.
64
+ const staleWarning = checkStaleness(action);
65
+
66
+ // Step 1: Locate the form
67
+ const form = locateForm(action);
68
+
69
+ if (!form) {
70
+ const base = `Form not found for action "${action.name}". The page may have changed since the tool was registered.`;
71
+ const result: FillResult = {
72
+ success: false,
73
+ submitted: false,
74
+ message: staleWarning ? `Warning: ${staleWarning} ${base}` : base,
75
+ filled: [],
76
+ skipped: [],
77
+ };
78
+ if (staleWarning) {
79
+ result.stale = true;
80
+ }
81
+ return JSON.stringify(result);
82
+ }
83
+
84
+ // Step 2: Reconcile fields
85
+ const reconciled = reconcileFields(action, form);
86
+ const specs = action.input ?? [];
87
+
88
+ // Step 3: Check for missing required fields
89
+ const filled: string[] = [];
90
+ const skipped: Array<{ field: string; reason: string }> = [];
91
+ // Tracks whether a required field went unfilled — drives the submit gate
92
+ // below. Kept as structured state rather than re-parsing skip messages.
93
+ let requiredMissing = false;
94
+
95
+ for (const pvs of specs) {
96
+ if (pvs.valueRequired && (args[pvs.valueName] === undefined || args[pvs.valueName] === null)) {
97
+ requiredMissing = true;
98
+ skipped.push({
99
+ field: pvs.valueName,
100
+ reason: `Required field '${pvs.valueName}' was not provided. Expected type: ${pvs['nxis:valueType']}.`,
101
+ });
102
+ }
103
+ }
104
+
105
+ // Step 4: Fill each reconciled field
106
+ for (const field of reconciled) {
107
+ const argValue = args[field.valueName];
108
+
109
+ if (argValue === undefined || argValue === null) {
110
+ // Already reported in required-field check above if required;
111
+ // for optional fields, note it but don't treat as error
112
+ if (!skipped.some(s => s.field === field.valueName)) {
113
+ skipped.push({ field: field.valueName, reason: 'No value provided (optional field).' });
114
+ }
115
+ continue;
116
+ }
117
+
118
+ const outcome = fillField(field, argValue);
119
+ if (outcome.ok === false) {
120
+ skipped.push({ field: field.valueName, reason: outcome.reason });
121
+ } else {
122
+ filled.push(field.valueName);
123
+ }
124
+ }
125
+
126
+ // Step 5: Settle — yield to the microtask queue so frameworks (React, Vue,
127
+ // Angular) can process the dispatched input/change events before we verify.
128
+ await new Promise(resolve => setTimeout(resolve, 0));
129
+
130
+ // Step 6: Verify — spot-check that filled values actually persisted.
131
+ // Frameworks may revert values (e.g., controlled components in React).
132
+ // Only the single-value text path is verifiable this way; radio/checkbox/
133
+ // select strategies resolve to a DOM-constrained value and aren't compared.
134
+ for (const field of reconciled) {
135
+ if (field.strategy.kind === 'value' && filled.includes(field.valueName)) {
136
+ const el = field.strategy.element;
137
+ const expected = valueToString(args[field.valueName] ?? '');
138
+ if (el.value !== expected) {
139
+ skipped.push({
140
+ field: field.valueName,
141
+ reason: `Value was set to "${expected}" but the DOM now shows "${el.value}". A framework may have reverted it.`,
142
+ });
143
+ filled.splice(filled.indexOf(field.valueName), 1);
144
+ }
145
+ }
146
+ }
147
+
148
+ // Step 7: Submit the form if fill was successful
149
+ const canSubmit = filled.length > 0 && !requiredMissing;
150
+ let submitted = false;
151
+
152
+ if (canSubmit) {
153
+ submitted = submitForm(form);
154
+ }
155
+
156
+ // Step 8: Build result
157
+ const result: FillResult = {
158
+ success: canSubmit,
159
+ submitted,
160
+ message: buildResultMessage(filled, skipped, submitted, staleWarning),
161
+ filled,
162
+ skipped,
163
+ };
164
+ if (staleWarning) {
165
+ result.stale = true;
166
+ }
167
+
168
+ return JSON.stringify(result);
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Internal — result message
173
+ // ---------------------------------------------------------------------------
174
+
175
+ /**
176
+ * Build a human-readable result message from the fill/skip arrays.
177
+ */
178
+ function buildResultMessage(
179
+ filled: string[],
180
+ skipped: Array<{ field: string; reason: string }>,
181
+ submitted: boolean,
182
+ staleWarning: string | null
183
+ ): string {
184
+ const parts: string[] = [];
185
+ if (staleWarning) {
186
+ parts.push(`Warning: ${staleWarning}`);
187
+ }
188
+
189
+ if (filled.length === 0 && skipped.length === 0) {
190
+ parts.push('No fields were filled. Verify field names match the input schema.');
191
+ return parts.join(' ');
192
+ }
193
+
194
+ if (filled.length > 0) {
195
+ parts.push(`Filled ${filled.length} field(s): ${filled.join(', ')}.`);
196
+ }
197
+
198
+ if (skipped.length > 0) {
199
+ const skipDetails = skipped
200
+ .map(s => `${s.field} (${s.reason})`)
201
+ .join('; ');
202
+ parts.push(`Skipped ${skipped.length}: ${skipDetails}`);
203
+ }
204
+
205
+ if (submitted) {
206
+ parts.push('Form submitted.');
207
+ } else if (filled.length > 0) {
208
+ parts.push('Form was NOT submitted due to missing required fields or fill errors.');
209
+ }
210
+
211
+ return parts.join(' ');
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Internal — form submission
216
+ // ---------------------------------------------------------------------------
217
+
218
+ /**
219
+ * Submit the form using requestSubmit() which, unlike form.submit():
220
+ * - Fires the 'submit' event (so JS handlers run)
221
+ * - Runs HTML5 constraint validation
222
+ * - Respects the form's action/method attributes
223
+ *
224
+ * Falls back to form.submit() if requestSubmit is unavailable.
225
+ *
226
+ * @returns `true` if submission was triggered without error.
227
+ */
228
+ function submitForm(form: HTMLFormElement): boolean {
229
+ try {
230
+ if (typeof form.requestSubmit === 'function') {
231
+ form.requestSubmit();
232
+ } else {
233
+ form.submit();
234
+ }
235
+ return true;
236
+ } catch (err) {
237
+ console.warn('[nxis/webmcp] Form submission failed:', err);
238
+ return false;
239
+ }
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Internal — fill logic
244
+ // ---------------------------------------------------------------------------
245
+
246
+ /**
247
+ * Fill a single reconciled field with a value.
248
+ * Returns an outcome with an actionable reason on failure.
249
+ */
250
+ function fillField(field: ReconciledField, value: unknown): FieldFillOutcome {
251
+ const { strategy } = field;
252
+
253
+ switch (strategy.kind) {
254
+ case 'skip':
255
+ return { ok: false, reason: strategy.reason };
256
+
257
+ case 'value':
258
+ return fillValue(strategy.element, valueToString(value));
259
+
260
+ case 'checked':
261
+ return fillChecked(strategy.element, value);
262
+
263
+ case 'radio':
264
+ return fillRadio(strategy.elements, value);
265
+
266
+ case 'checkboxGroup':
267
+ return fillCheckboxGroup(strategy.elements, value);
268
+
269
+ case 'select':
270
+ return fillSelect(strategy.element, String(value), strategy.allowedValues);
271
+
272
+ case 'multiSelect':
273
+ return fillMultiSelect(strategy.element, value, strategy.allowedValues);
274
+
275
+ default:
276
+ return { ok: false, reason: `Unsupported fill strategy for field "${field.valueName}".` };
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Stringify a fill value. Arrays (from `multipleValues` fields backed by a
282
+ * plain control, e.g. `<input type="email" multiple>`) join comma-separated.
283
+ */
284
+ function valueToString(value: unknown): string {
285
+ if (Array.isArray(value)) {
286
+ return value.map(v => String(v)).join(',');
287
+ }
288
+ return String(value);
289
+ }
290
+
291
+ /**
292
+ * Set .value on an input/textarea and dispatch events.
293
+ */
294
+ function fillValue(element: HTMLInputElement | HTMLTextAreaElement, value: string): FieldFillOutcome {
295
+ // Use the native setter to bypass framework interceptors
296
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
297
+ element instanceof HTMLTextAreaElement
298
+ ? HTMLTextAreaElement.prototype
299
+ : HTMLInputElement.prototype,
300
+ 'value'
301
+ )?.set;
302
+
303
+ if (nativeInputValueSetter) {
304
+ nativeInputValueSetter.call(element, value);
305
+ } else {
306
+ element.value = value;
307
+ }
308
+
309
+ element.dispatchEvent(new Event('input', { bubbles: true }));
310
+ element.dispatchEvent(new Event('change', { bubbles: true }));
311
+ return { ok: true };
312
+ }
313
+
314
+ /**
315
+ * Toggle a single checkbox and dispatch change event.
316
+ */
317
+ function fillChecked(element: HTMLInputElement, value: unknown): FieldFillOutcome {
318
+ element.checked = toBoolean(value);
319
+ element.dispatchEvent(new Event('change', { bubbles: true }));
320
+ return { ok: true };
321
+ }
322
+
323
+ /**
324
+ * Select the member of a radio group whose value matches. Matching tries the
325
+ * exact value, then case-insensitive value, then the visible label — LLMs
326
+ * often send the human-readable label instead of the machine value.
327
+ */
328
+ function fillRadio(elements: HTMLInputElement[], value: unknown): FieldFillOutcome {
329
+ const target = String(value);
330
+ const match =
331
+ elements.find(el => el.value === target) ??
332
+ elements.find(el => el.value.toLowerCase() === target.toLowerCase()) ??
333
+ elements.find(el => radioLabel(el).toLowerCase() === target.toLowerCase());
334
+
335
+ if (!match) {
336
+ const allowed = elements.map(el => el.value).join(', ');
337
+ return {
338
+ ok: false,
339
+ reason: `Value "${target}" is not an allowed option for radio "${elements[0].name}". Allowed: [${allowed}].`,
340
+ };
341
+ }
342
+
343
+ match.checked = true;
344
+ match.dispatchEvent(new Event('input', { bubbles: true }));
345
+ match.dispatchEvent(new Event('change', { bubbles: true }));
346
+ return { ok: true };
347
+ }
348
+
349
+ /**
350
+ * Check the members of a checkbox group named by the given value(s); uncheck
351
+ * the rest. Accepts a single value or an array of values.
352
+ */
353
+ function fillCheckboxGroup(elements: HTMLInputElement[], value: unknown): FieldFillOutcome {
354
+ const wanted = (Array.isArray(value) ? value : [value]).map(v => String(v));
355
+ const wantedLower = wanted.map(w => w.toLowerCase());
356
+
357
+ let matchedAny = false;
358
+ for (const el of elements) {
359
+ const selected =
360
+ wanted.includes(el.value) || wantedLower.includes(el.value.toLowerCase());
361
+ el.checked = selected;
362
+ if (selected) matchedAny = true;
363
+ el.dispatchEvent(new Event('change', { bubbles: true }));
364
+ }
365
+
366
+ if (!matchedAny) {
367
+ const allowed = elements.map(el => el.value).join(', ');
368
+ return {
369
+ ok: false,
370
+ reason: `None of [${wanted.join(', ')}] match checkbox group "${elements[0].name}". Allowed: [${allowed}].`,
371
+ };
372
+ }
373
+ return { ok: true };
374
+ }
375
+
376
+ /**
377
+ * Set .value on a single-select <select> from allowed options.
378
+ * Constrains to actual <option> values regardless of what the Action claimed.
379
+ */
380
+ function fillSelect(
381
+ element: HTMLSelectElement,
382
+ value: string,
383
+ allowedValues: string[]
384
+ ): FieldFillOutcome {
385
+ const resolved = resolveOption(element, value, allowedValues);
386
+ if (resolved === null) {
387
+ return {
388
+ ok: false,
389
+ reason: `Value "${value}" is not an allowed option for "${element.name}". Allowed: [${allowedValues.join(', ')}].`,
390
+ };
391
+ }
392
+
393
+ element.value = resolved;
394
+ element.dispatchEvent(new Event('input', { bubbles: true }));
395
+ element.dispatchEvent(new Event('change', { bubbles: true }));
396
+ return { ok: true };
397
+ }
398
+
399
+ /**
400
+ * Select one or more options on a <select multiple>. Accepts a single value or
401
+ * an array; succeeds if at least one value resolves to a real option.
402
+ */
403
+ function fillMultiSelect(
404
+ element: HTMLSelectElement,
405
+ value: unknown,
406
+ allowedValues: string[]
407
+ ): FieldFillOutcome {
408
+ const requested = (Array.isArray(value) ? value : [value]).map(v => String(v));
409
+ const resolved = new Set<string>();
410
+ const unmatched: string[] = [];
411
+
412
+ for (const v of requested) {
413
+ const match = resolveOption(element, v, allowedValues);
414
+ if (match === null) {
415
+ unmatched.push(v);
416
+ } else {
417
+ resolved.add(match);
418
+ }
419
+ }
420
+
421
+ if (resolved.size === 0) {
422
+ return {
423
+ ok: false,
424
+ reason: `None of [${requested.join(', ')}] are allowed options for "${element.name}". Allowed: [${allowedValues.join(', ')}].`,
425
+ };
426
+ }
427
+
428
+ for (const option of Array.from(element.options)) {
429
+ option.selected = resolved.has(option.value);
430
+ }
431
+ element.dispatchEvent(new Event('input', { bubbles: true }));
432
+ element.dispatchEvent(new Event('change', { bubbles: true }));
433
+
434
+ if (unmatched.length > 0) {
435
+ return {
436
+ ok: false,
437
+ reason: `Selected [${Array.from(resolved).join(', ')}] but [${unmatched.join(', ')}] are not allowed options for "${element.name}". Allowed: [${allowedValues.join(', ')}].`,
438
+ };
439
+ }
440
+ return { ok: true };
441
+ }
442
+
443
+ /**
444
+ * Resolve a requested value to a real <option> value: exact, then
445
+ * case-insensitive, then by visible option text. Returns null if no match.
446
+ */
447
+ function resolveOption(
448
+ element: HTMLSelectElement,
449
+ value: string,
450
+ allowedValues: string[]
451
+ ): string | null {
452
+ if (allowedValues.includes(value)) return value;
453
+
454
+ const ci = allowedValues.find(v => v.toLowerCase() === value.toLowerCase());
455
+ if (ci) return ci;
456
+
457
+ // LLMs often send the human-readable label instead of the machine value,
458
+ // e.g. "AI Summit 2026" instead of "ai-summit-2026".
459
+ for (const option of Array.from(element.options)) {
460
+ if (option.text.toLowerCase() === value.toLowerCase()) {
461
+ return option.value;
462
+ }
463
+ }
464
+ return null;
465
+ }
466
+
467
+ /**
468
+ * Coerce a JSON value to a checkbox boolean. Mirrors how an agent might encode
469
+ * "off": false, "false", "0", "" and null/undefined all read as unchecked.
470
+ */
471
+ function toBoolean(value: unknown): boolean {
472
+ if (typeof value === 'boolean') return value;
473
+ if (typeof value === 'number') return value !== 0;
474
+ if (typeof value === 'string') {
475
+ const v = value.trim().toLowerCase();
476
+ return v !== '' && v !== 'false' && v !== '0' && v !== 'off' && v !== 'no';
477
+ }
478
+ return Boolean(value);
479
+ }
480
+
481
+ /**
482
+ * Best-effort visible label for a radio input — its associated <label> text,
483
+ * falling back to the value. Used to match agent-supplied human labels.
484
+ */
485
+ function radioLabel(el: HTMLInputElement): string {
486
+ if (el.labels && el.labels.length > 0) {
487
+ return (el.labels[0].textContent ?? '').trim();
488
+ }
489
+ return el.value;
490
+ }
491
+
492
+ /**
493
+ * Compare the action's recorded extraction page against the live location.
494
+ * Returns a warning string when they differ (origin or path), else null.
495
+ */
496
+ function checkStaleness(action: NxisAction): string | null {
497
+ const pageUrl = action.target['nxis:pageUrl'];
498
+ if (!pageUrl) return null;
499
+
500
+ try {
501
+ const recorded = new URL(pageUrl);
502
+ const current = new URL(window.location.href);
503
+ if (recorded.origin !== current.origin || recorded.pathname !== current.pathname) {
504
+ return (
505
+ `Action "${action.name}" was extracted from ${recorded.origin}${recorded.pathname} ` +
506
+ `but the current page is ${current.origin}${current.pathname}. ` +
507
+ `The form may have moved or changed.`
508
+ );
509
+ }
510
+ } catch {
511
+ return null;
512
+ }
513
+ return null;
514
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @packageDocumentation
3
+ *
4
+ * `nxis/webmcp` — WebMCP tool registration for NxisAI structured data.
5
+ *
6
+ * @remarks
7
+ * This subpath export reads Action + EntryPoint JSON-LD injected by the
8
+ * NxisAI backend and registers each Action as a WebMCP tool via
9
+ * `document.modelContext.registerTool`. Each tool's `execute` locates the
10
+ * real `<form>` on the live page and fills it.
11
+ *
12
+ * **Browser-only.** This module requires `document.modelContext` (Chrome 149+).
13
+ * It gracefully no-ops in environments where WebMCP is unavailable.
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * import { registerActions } from 'nxis/webmcp';
18
+ *
19
+ * // Auto-register all Action tools found in the page's JSON-LD
20
+ * const cleanup = registerActions();
21
+ *
22
+ * // Later, to unregister all tools:
23
+ * cleanup();
24
+ * ```
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * // Opt-out of MutationObserver re-scan for SPAs
29
+ * const cleanup = registerActions({ observe: false });
30
+ * ```
31
+ */
32
+
33
+ export { registerActions } from './register';
34
+ export type { RegisterOptions } from './register';
35
+
36
+ // Re-export contract types for consumers who need them
37
+ export type {
38
+ NxisAction,
39
+ NxisEntryPoint,
40
+ NxisPropertyValueSpecification,
41
+ NxisValueOption,
42
+ NxisValueType,
43
+ ActionSubtype,
44
+ } from './contract';
45
+ export { isActionType, ACTION_SUBTYPES } from './contract';
46
+
47
+ // Re-export parse for advanced usage
48
+ export { readActions } from './parse';
49
+
50
+ // Re-export schema for testing / advanced usage
51
+ export { buildInputSchema } from './schema';