@nxisai/nxis 2.1.2 → 2.2.2

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,285 @@
1
+ /**
2
+ * Tool registration — registers NxisActions as WebMCP tools.
3
+ *
4
+ * Ensures WebMCP availability via @nxisai/webmcp polyfill (no-ops when
5
+ * a native implementation exists, e.g. Chrome 149+).
6
+ *
7
+ * Uses `document.modelContext` (canonical, Chrome 150+) with
8
+ * `navigator.modelContext` fallback for older builds.
9
+ *
10
+ * @internal
11
+ */
12
+
13
+ import './types'; // Augment Document/Navigator with modelContext
14
+ import { installWebMCPPolyfill } from '@nxisai/webmcp';
15
+ import { NxisAction } from './contract';
16
+ import { readActions } from './parse';
17
+ import { buildInputSchema } from './schema';
18
+ import { executeAction } from './execute';
19
+ import type { ModelContext } from './types';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Public types
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface RegisterOptions {
26
+ /** Document to scan for JSON-LD blocks. Defaults to `document`. */
27
+ doc?: Document;
28
+ /** AbortSignal to externally cancel all registrations. */
29
+ signal?: AbortSignal;
30
+ /** Enable MutationObserver re-scan for SPAs. Defaults to `true` (opt-out). */
31
+ observe?: boolean;
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Public API
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Parse all Action JSON-LD from the page and register each as a WebMCP tool.
40
+ *
41
+ * Automatically installs the @nxisai/webmcp polyfill on browsers without
42
+ * native WebMCP support. The polyfill is a no-op when `document.modelContext`
43
+ * already exists (Chrome 149+).
44
+ *
45
+ * @param options - Configuration options.
46
+ * @returns A cleanup function that unregisters all tools and disconnects observers.
47
+ */
48
+ export function registerActions(options?: RegisterOptions): () => void {
49
+ const observe = options?.observe !== false; // opt-out: default true
50
+
51
+ // Browser-only — no-op gracefully when there's no DOM (SSR, Node, workers).
52
+ const doc = options?.doc ?? (typeof document !== 'undefined' ? document : undefined);
53
+ if (!doc) {
54
+ console.warn(
55
+ '[nxis/webmcp] No document available (not running in a browser). ' +
56
+ 'Tools will not be registered.'
57
+ );
58
+ return () => { };
59
+ }
60
+
61
+ // Ensure WebMCP API is available — polyfill no-ops on Chrome 149+
62
+ installWebMCPPolyfill();
63
+
64
+ // Resolve the ModelContext from the SAME document we parse, so a custom
65
+ // `doc` (e.g. an iframe) registers its tools on the right context.
66
+ const modelContext = getModelContext(doc);
67
+ if (!modelContext) {
68
+ console.warn(
69
+ '[nxis/webmcp] WebMCP API not available — document.modelContext is undefined. ' +
70
+ 'Tools will not be registered.'
71
+ );
72
+ return () => { };
73
+ }
74
+
75
+ const controller = new AbortController();
76
+
77
+ // Link external signal to our controller
78
+ if (options?.signal) {
79
+ if (options.signal.aborted) {
80
+ controller.abort();
81
+ } else {
82
+ options.signal.addEventListener('abort', () => controller.abort());
83
+ }
84
+ }
85
+
86
+ // Registry shared across the initial scan and observer re-scans.
87
+ const registry: Registry = {
88
+ ids: new Set<string>(),
89
+ names: new Set<string>(),
90
+ };
91
+
92
+ // Initial registration
93
+ registerFromDocument(doc, modelContext, controller, registry);
94
+
95
+ // SPA re-scan via MutationObserver (always-on, opt-out via observe: false)
96
+ let observer: MutationObserver | null = null;
97
+ if (observe) {
98
+ observer = createObserver(doc, modelContext, controller, registry);
99
+ }
100
+
101
+ // Return cleanup function
102
+ return () => {
103
+ controller.abort();
104
+ if (observer) {
105
+ observer.disconnect();
106
+ observer = null;
107
+ }
108
+ };
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Internal
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /** De-dupe state shared across the initial scan and observer re-scans. */
116
+ interface Registry {
117
+ /** Action @ids already registered — prevents re-registering the same action. */
118
+ ids: Set<string>;
119
+ /** Tool names already taken — prevents distinct actions colliding on a name. */
120
+ names: Set<string>;
121
+ }
122
+
123
+ /**
124
+ * Resolve the ModelContext for a given document using Chrome team's
125
+ * recommended feature-detection. `doc.modelContext` is canonical (Chrome 150+);
126
+ * `navigator.modelContext` (from the document's own window) is the deprecated
127
+ * fallback.
128
+ */
129
+ function getModelContext(doc: Document): ModelContext | null {
130
+ const nav = doc.defaultView?.navigator ??
131
+ (typeof navigator !== 'undefined' ? navigator : undefined);
132
+ return doc.modelContext || nav?.modelContext || null;
133
+ }
134
+
135
+ /**
136
+ * Register tools for all Action specs found in the document.
137
+ */
138
+ function registerFromDocument(
139
+ doc: Document,
140
+ modelContext: ModelContext,
141
+ controller: AbortController,
142
+ registry: Registry
143
+ ): void {
144
+ const actions = readActions(doc);
145
+
146
+ let registeredThisPass = 0;
147
+ for (const action of actions) {
148
+ // De-dupe by @id
149
+ if (registry.ids.has(action['@id'])) continue;
150
+
151
+ try {
152
+ registerSingleAction(action, modelContext, controller, registry);
153
+ registry.ids.add(action['@id']);
154
+ registeredThisPass++;
155
+ } catch (err) {
156
+ console.warn(
157
+ `[nxis/webmcp] Failed to register tool for action "${action.name}":`,
158
+ err
159
+ );
160
+ }
161
+ }
162
+
163
+ if (registeredThisPass > 0) {
164
+ console.info(
165
+ `[nxis/webmcp] Registered ${registeredThisPass} tool(s) ` +
166
+ `(${registry.ids.size} total) from ${actions.length} action(s).`
167
+ );
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Register a single NxisAction as a WebMCP tool.
173
+ */
174
+ function registerSingleAction(
175
+ action: NxisAction,
176
+ modelContext: ModelContext,
177
+ controller: AbortController,
178
+ registry: Registry
179
+ ): void {
180
+ const inputSchema = buildInputSchema(action);
181
+ const name = uniqueToolName(sanitizeToolName(action.name), registry.names);
182
+
183
+ modelContext.registerTool(
184
+ {
185
+ name,
186
+ description: action.description,
187
+ inputSchema,
188
+ annotations: {
189
+ readOnlyHint: false,
190
+ untrustedContentHint: true,
191
+ },
192
+ execute: async (args: Record<string, unknown>) => {
193
+ return executeAction(action, args);
194
+ },
195
+ },
196
+ { signal: controller.signal },
197
+ );
198
+
199
+ // Reserve the name only after a successful registration, so a failed
200
+ // registerTool doesn't consume the base name and push the next action to a suffix.
201
+ registry.names.add(name);
202
+ }
203
+
204
+ /**
205
+ * Create a MutationObserver that watches the whole document for new JSON-LD
206
+ * insertions. SPAs inject structured data into <body>, framework roots, or
207
+ * inside wrapper elements — not just <head> — so we observe the document
208
+ * subtree and detect scripts added either directly or as descendants.
209
+ * On mutation, re-registers any new actions (de-duped by @id).
210
+ */
211
+ function createObserver(
212
+ doc: Document,
213
+ modelContext: ModelContext,
214
+ controller: AbortController,
215
+ registry: Registry
216
+ ): MutationObserver {
217
+ const observer = new MutationObserver((mutations) => {
218
+ if (mutationsAddJsonLd(mutations)) {
219
+ registerFromDocument(doc, modelContext, controller, registry);
220
+ }
221
+ });
222
+
223
+ // Stop observing when the registration is torn down.
224
+ controller.signal.addEventListener('abort', () => observer.disconnect());
225
+
226
+ observer.observe(doc.documentElement, {
227
+ childList: true,
228
+ subtree: true,
229
+ });
230
+
231
+ return observer;
232
+ }
233
+
234
+ /** True if any mutation added a JSON-LD script, directly or nested. */
235
+ function mutationsAddJsonLd(mutations: MutationRecord[]): boolean {
236
+ for (const mutation of mutations) {
237
+ for (const node of Array.from(mutation.addedNodes)) {
238
+ if (isJsonLdScript(node)) return true;
239
+ // A wrapper element may carry JSON-LD scripts as descendants.
240
+ if (
241
+ node instanceof Element &&
242
+ node.querySelector('script[type="application/ld+json"]')
243
+ ) {
244
+ return true;
245
+ }
246
+ }
247
+ }
248
+ return false;
249
+ }
250
+
251
+ /** True if the node is itself a JSON-LD script element. */
252
+ function isJsonLdScript(node: Node): boolean {
253
+ return node instanceof HTMLScriptElement && node.type === 'application/ld+json';
254
+ }
255
+
256
+ /**
257
+ * Sanitize an action name to a valid snake_case tool name.
258
+ * Converts hyphens and spaces to underscores, strips invalid chars.
259
+ * WebMCP tool names: [a-z0-9_], max 64 chars.
260
+ */
261
+ function sanitizeToolName(name: string): string {
262
+ return name
263
+ .toLowerCase()
264
+ .replace(/[\s-]+/g, '_')
265
+ .replace(/[^a-z0-9_]/g, '_')
266
+ .replace(/_+/g, '_')
267
+ .replace(/^_|_$/g, '')
268
+ .slice(0, 64) || 'unnamed_action';
269
+ }
270
+
271
+ /**
272
+ * Ensure a tool name is unique within the document. Distinct actions whose
273
+ * names sanitize to the same string get a numeric suffix (`_2`, `_3`, …)
274
+ * rather than silently overwriting one another. The suffix is kept within the
275
+ * 64-char tool-name limit.
276
+ */
277
+ function uniqueToolName(base: string, taken: Set<string>): string {
278
+ if (!taken.has(base)) return base;
279
+
280
+ for (let i = 2; ; i++) {
281
+ const suffix = `_${i}`;
282
+ const candidate = base.slice(0, 64 - suffix.length) + suffix;
283
+ if (!taken.has(candidate)) return candidate;
284
+ }
285
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Schema transform — converts NxisAction PVS descriptors to JSON Schema
3
+ * for the WebMCP `registerTool` inputSchema.
4
+ *
5
+ * Pure function, no DOM access.
6
+ *
7
+ * Mapping rules (from implementation plan):
8
+ * - valueName → property key
9
+ * - nxis:valueType → JSON Schema "type" (date/file → "string")
10
+ * - valueRequired → sibling "required" array (not per-property keyword)
11
+ * - valuePattern → anchored with ^…$ (emitted unanchored by producer)
12
+ * - multipleValues → nests into { type: "array", items: { ... } }
13
+ * - nxis:valueOption → "enum" or "oneOf" with { const, title }
14
+ * - defaultValue → "default"
15
+ * - description → "description"
16
+ * - valueMinLength/valueMaxLength → minLength/maxLength
17
+ * - minValue/maxValue → minimum/maximum
18
+ * - stepValue → multipleOf
19
+ * - readonlyValue → readOnly: true
20
+ *
21
+ * @internal
22
+ */
23
+
24
+ import { NxisAction, NxisPropertyValueSpecification, NxisValueType } from './contract';
25
+
26
+ /** JSON Schema type mapping from nxis:valueType. */
27
+ function jsonSchemaType(vt: NxisValueType): string {
28
+ switch (vt) {
29
+ case 'string': return 'string';
30
+ case 'number': return 'number';
31
+ case 'integer': return 'integer';
32
+ case 'boolean': return 'boolean';
33
+ case 'date': return 'string'; // ISO date string
34
+ case 'file': return 'string'; // file path/name as string
35
+ default: return 'string';
36
+ }
37
+ }
38
+
39
+ /** JSON Schema format for types that have standard formats. */
40
+ function jsonSchemaFormat(vt: NxisValueType): string | undefined {
41
+ switch (vt) {
42
+ case 'date': return 'date'; // ISO 8601 (YYYY-MM-DD)
43
+ default: return undefined;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Build a JSON Schema object for the Action's input parameters.
49
+ *
50
+ * @param action - The parsed Action to transform.
51
+ * @returns A JSON Schema `{ type: "object", properties: { ... }, required: [...] }`.
52
+ */
53
+ export function buildInputSchema(action: NxisAction): Record<string, unknown> {
54
+ const properties: Record<string, Record<string, unknown>> = {};
55
+ const required: string[] = [];
56
+ const specs = action.input ?? [];
57
+
58
+ for (const pvs of specs) {
59
+ const prop = buildPropertySchema(pvs);
60
+
61
+ if (pvs.multipleValues) {
62
+ // Wrap in array schema
63
+ const arrayProp: Record<string, unknown> = {
64
+ type: 'array',
65
+ items: prop,
66
+ };
67
+ // Move minLength/maxLength to minItems/maxItems on the array
68
+ if (pvs.valueMinLength !== undefined) {
69
+ arrayProp.minItems = pvs.valueMinLength;
70
+ delete prop.minLength;
71
+ }
72
+ if (pvs.valueMaxLength !== undefined) {
73
+ arrayProp.maxItems = pvs.valueMaxLength;
74
+ delete prop.maxLength;
75
+ }
76
+ // Preserve description on the array level only (avoid duplicating it on
77
+ // both the array and its items).
78
+ if (pvs.description) {
79
+ arrayProp.description = pvs.description;
80
+ delete prop.description;
81
+ }
82
+ properties[pvs.valueName] = arrayProp;
83
+ } else {
84
+ properties[pvs.valueName] = prop;
85
+ }
86
+
87
+ // valueRequired relocates to the sibling required array
88
+ if (pvs.valueRequired) {
89
+ required.push(pvs.valueName);
90
+ }
91
+ }
92
+
93
+ const schema: Record<string, unknown> = {
94
+ type: 'object',
95
+ properties,
96
+ };
97
+
98
+ if (required.length > 0) {
99
+ schema.required = required;
100
+ }
101
+
102
+ return schema;
103
+ }
104
+
105
+ /**
106
+ * Build a single property schema from a PVS descriptor.
107
+ */
108
+ function buildPropertySchema(pvs: NxisPropertyValueSpecification): Record<string, unknown> {
109
+ const prop: Record<string, unknown> = {
110
+ type: jsonSchemaType(pvs['nxis:valueType']),
111
+ };
112
+
113
+ // Format hint for date/email fields
114
+ const format = jsonSchemaFormat(pvs['nxis:valueType']);
115
+ if (format) {
116
+ prop.format = format;
117
+ }
118
+
119
+ // Description
120
+ if (pvs.description) {
121
+ prop.description = pvs.description;
122
+ }
123
+
124
+ // Default value
125
+ if (pvs.defaultValue !== undefined) {
126
+ prop.default = coerceDefault(pvs.defaultValue, pvs['nxis:valueType']);
127
+ }
128
+
129
+ // String constraints
130
+ if (pvs.valueMinLength !== undefined) {
131
+ prop.minLength = pvs.valueMinLength;
132
+ }
133
+ if (pvs.valueMaxLength !== undefined) {
134
+ prop.maxLength = pvs.valueMaxLength;
135
+ }
136
+
137
+ // Pattern — anchor with ^…$ (producer emits unanchored)
138
+ if (pvs.valuePattern) {
139
+ const pattern = pvs.valuePattern;
140
+ const anchored = pattern.startsWith('^') && pattern.endsWith('$')
141
+ ? pattern
142
+ : `^${pattern}$`;
143
+ prop.pattern = anchored;
144
+ }
145
+
146
+ // Numeric constraints
147
+ if (pvs.minValue !== undefined) {
148
+ prop.minimum = pvs.minValue;
149
+ }
150
+ if (pvs.maxValue !== undefined) {
151
+ prop.maximum = pvs.maxValue;
152
+ }
153
+ if (pvs.stepValue !== undefined) {
154
+ prop.multipleOf = pvs.stepValue;
155
+ }
156
+
157
+ // Read-only
158
+ if (pvs.readonlyValue) {
159
+ prop.readOnly = true;
160
+ }
161
+
162
+ // Options → always emit enum (LLMs handle enum + description far more
163
+ // reliably than oneOf with { const, title } — they anchor on the human
164
+ // title and send that instead of the machine const value).
165
+ const options = pvs['nxis:valueOption'];
166
+ if (options && options.length > 0) {
167
+ prop.enum = options.map(o => o.value);
168
+
169
+ // When labels differ from values, append a human-readable mapping
170
+ // to the description so the LLM can understand what each value means
171
+ // while still sending the correct enum value.
172
+ const hasLabels = options.some(o => o.name !== undefined && o.name !== o.value);
173
+ if (hasLabels) {
174
+ const labelMap = options
175
+ .filter(o => o.name && o.name !== o.value)
176
+ .map(o => `${o.value} = "${o.name}"`)
177
+ .join(', ');
178
+ const existing = prop.description ? `${prop.description}. ` : '';
179
+ prop.description = `${existing}Values: ${labelMap}`;
180
+ }
181
+ }
182
+
183
+ return prop;
184
+ }
185
+
186
+ /**
187
+ * Coerce a string default value to the appropriate type.
188
+ */
189
+ function coerceDefault(value: string, valueType: NxisValueType): unknown {
190
+ switch (valueType) {
191
+ case 'number':
192
+ case 'integer': {
193
+ const n = Number(value);
194
+ return isNaN(n) ? value : n;
195
+ }
196
+ case 'boolean':
197
+ return value === 'true' || value === '1';
198
+ default:
199
+ return value;
200
+ }
201
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Type declarations for the WebMCP imperative API.
3
+ *
4
+ * Augments the global `Document` and `Navigator` interfaces with
5
+ * `modelContext` so TypeScript recognizes the WebMCP surface.
6
+ *
7
+ * `document.modelContext` is canonical (Chrome 150+).
8
+ * `navigator.modelContext` is deprecated but supported as fallback.
9
+ *
10
+ * @see https://developer.chrome.com/docs/ai/webmcp/imperative-api
11
+ * @internal
12
+ */
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Tool registration types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** Annotations that hint to the agent how to treat a tool. */
19
+ export interface ToolAnnotations {
20
+ /**
21
+ * If true, the tool only reads data and does not mutate the page.
22
+ * Set to `false` for form-filling tools (we mutate input values).
23
+ */
24
+ readOnlyHint?: boolean;
25
+ /**
26
+ * If true, the tool's output contains untrusted content from the page.
27
+ * Always `true` for NxisAI tools — we infer from content we don't control.
28
+ */
29
+ untrustedContentHint?: boolean;
30
+ }
31
+
32
+ /** The shape passed to `modelContext.registerTool()`. */
33
+ export interface ModelContextTool {
34
+ /** Tool name — unique within the document. */
35
+ name: string;
36
+ /** Human-readable description of what the tool does. */
37
+ description: string;
38
+ /**
39
+ * JSON Schema describing the tool's input parameters.
40
+ * Passed as a plain object (type: "object" with properties).
41
+ */
42
+ inputSchema: Record<string, unknown>;
43
+ /** Behavioral hints for the agent. */
44
+ annotations?: ToolAnnotations;
45
+ /**
46
+ * Called by the agent to execute the tool.
47
+ * Must return a string (JSON-serialized result).
48
+ */
49
+ execute: (args: Record<string, unknown>) => string | Promise<string>;
50
+ }
51
+
52
+ /** Options for `modelContext.registerTool()`. */
53
+ export interface RegisterToolOptions {
54
+ /** AbortSignal to unregister the tool when aborted. */
55
+ signal?: AbortSignal;
56
+ }
57
+
58
+ /** The ModelContext interface exposed on `document.modelContext`. */
59
+ export interface ModelContext extends EventTarget {
60
+ /** Register a tool for agents to discover and invoke. */
61
+ registerTool(tool: ModelContextTool, options?: RegisterToolOptions): void;
62
+ /** Get all currently registered tools. */
63
+ getTools(): ModelContextTool[];
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Global augmentation
68
+ // ---------------------------------------------------------------------------
69
+
70
+ declare global {
71
+ interface Document {
72
+ /**
73
+ * The WebMCP model context (canonical, Chrome 150+).
74
+ * Tools are per-Document, not per-Navigator.
75
+ */
76
+ modelContext?: ModelContext;
77
+ }
78
+
79
+ interface Navigator {
80
+ /**
81
+ * @deprecated Use `document.modelContext` instead.
82
+ * Deprecated as of Chrome 150.0.7861.0.
83
+ */
84
+ modelContext?: ModelContext;
85
+ }
86
+ }