paseo-form-render 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ import {
2
+ capIssues,
3
+ findDuplicateIdIssues,
4
+ formatIssues,
5
+ formatJsonParseError,
6
+ formatZodIssues,
7
+ type FormIssue,
8
+ } from "./form-errors";
9
+ import { PfrForm, PfrFormSchema } from "./schema";
10
+
11
+ export interface ParsedBlock {
12
+ type: "text" | "form";
13
+ content?: string;
14
+ form?: PfrForm;
15
+ rawJson?: string;
16
+ /** Structured problems (at most 10, plus an "...and N more" entry); set when the block failed. */
17
+ errors?: FormIssue[];
18
+ /** `errors` joined one per line, for display. */
19
+ error?: string;
20
+ }
21
+
22
+ interface FenceBlock {
23
+ start: number;
24
+ contentStart: number;
25
+ contentEnd: number;
26
+ end: number;
27
+ }
28
+
29
+ function findBlocks(text: string): FenceBlock[] {
30
+ const blocks: FenceBlock[] = [];
31
+ const fences = /^ {0,3}(`{3,}|~{3,})([^\r\n]*)\r?$/gm;
32
+ let opening: RegExpExecArray | null;
33
+
34
+ while ((opening = fences.exec(text)) !== null) {
35
+ const marker = opening[1];
36
+ const label = opening[2].trim();
37
+ const contentStart = opening.index + opening[0].length;
38
+ const closing = new RegExp(
39
+ `^ {0,3}${marker[0]}{${marker.length},}[ \\t]*\\r?$`,
40
+ "gm",
41
+ );
42
+ closing.lastIndex = contentStart;
43
+ const end = closing.exec(text);
44
+
45
+ if (label === "pfr-form") {
46
+ blocks.push({
47
+ start: opening.index,
48
+ contentStart,
49
+ contentEnd: end ? end.index : text.length,
50
+ end: end ? end.index + end[0].length : text.length,
51
+ });
52
+ }
53
+
54
+ if (!end) break;
55
+ fences.lastIndex = end.index + end[0].length;
56
+ }
57
+
58
+ return blocks;
59
+ }
60
+
61
+ export function hasFormBlock(text: string): boolean {
62
+ return findBlocks(text).length > 0;
63
+ }
64
+
65
+ function failedBlock(rawJson: string, issues: FormIssue[]): ParsedBlock {
66
+ const errors = capIssues(issues);
67
+ return { type: "form", rawJson, errors, error: formatIssues(errors).join("\n") };
68
+ }
69
+
70
+ export function parseFormBlock(jsonStr: string): ParsedBlock {
71
+ const trimmed = jsonStr.trim();
72
+ let parsedJson: unknown;
73
+ try {
74
+ parsedJson = JSON.parse(trimmed);
75
+ } catch (e) {
76
+ return failedBlock(trimmed, formatJsonParseError(trimmed, e));
77
+ }
78
+
79
+ const validation = PfrFormSchema.safeParse(parsedJson);
80
+ const issues = [
81
+ ...(validation.success ? [] : formatZodIssues(validation.error.issues, parsedJson)),
82
+ ...findDuplicateIdIssues(parsedJson),
83
+ ];
84
+ if (issues.length > 0) {
85
+ return failedBlock(trimmed, issues);
86
+ }
87
+ return { type: "form", form: validation.data as PfrForm, rawJson: trimmed };
88
+ }
89
+
90
+ export function parseMessageContent(text: string): ParsedBlock[] {
91
+ const blocks: ParsedBlock[] = [];
92
+ const fenceBlocks = findBlocks(text);
93
+ let cursor = 0;
94
+
95
+ for (const block of fenceBlocks) {
96
+ // Leading text
97
+ if (block.start > cursor) {
98
+ const leadingText = text.slice(cursor, block.start).trim();
99
+ if (leadingText) {
100
+ blocks.push({ type: "text", content: leadingText });
101
+ }
102
+ }
103
+
104
+ const jsonStr = text.slice(block.contentStart, block.contentEnd).trim();
105
+ blocks.push(parseFormBlock(jsonStr));
106
+
107
+ cursor = block.end;
108
+ }
109
+
110
+ // Trailing text
111
+ if (cursor < text.length) {
112
+ const trailingText = text.slice(cursor).trim();
113
+ if (trailingText) {
114
+ blocks.push({ type: "text", content: trailingText });
115
+ }
116
+ }
117
+
118
+ return blocks;
119
+ }
@@ -0,0 +1,50 @@
1
+ import { defineRpc } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+
4
+ export const ProjectFileEntrySchema = z.object({
5
+ name: z.string(),
6
+ isDirectory: z.boolean(),
7
+ relativePath: z.string(),
8
+ fullPath: z.string().optional(),
9
+ });
10
+ export type ProjectFileEntry = z.infer<typeof ProjectFileEntrySchema>;
11
+
12
+ export const FilePickerShortcutSchema = z.object({
13
+ id: z.string(),
14
+ label: z.string(),
15
+ icon: z.string(),
16
+ path: z.string(),
17
+ });
18
+ export type FilePickerShortcut = z.infer<typeof FilePickerShortcutSchema>;
19
+
20
+ export const listProjectFilesRpc = defineRpc({
21
+ name: "pfr.list-project-files",
22
+ input: z.object({
23
+ agentId: z.string(),
24
+ subPath: z.string().optional(),
25
+ targetPath: z.string().optional(),
26
+ }),
27
+ output: z.object({
28
+ currentPath: z.string(),
29
+ parentPath: z.string().nullable(),
30
+ entries: z.array(ProjectFileEntrySchema),
31
+ shortcuts: z.array(FilePickerShortcutSchema).optional(),
32
+ isWorkspaceRelative: z.boolean().optional(),
33
+ error: z.string().optional(),
34
+ }),
35
+ });
36
+
37
+ export const createDirectoryRpc = defineRpc({
38
+ name: "pfr.create-directory",
39
+ input: z.object({
40
+ agentId: z.string(),
41
+ parentPath: z.string().min(1),
42
+ name: z.string().min(1),
43
+ }),
44
+ output: z.object({
45
+ success: z.boolean(),
46
+ fullPath: z.string().optional(),
47
+ relativePath: z.string().optional(),
48
+ error: z.string().optional(),
49
+ }),
50
+ });
@@ -0,0 +1,96 @@
1
+ import { PFR_FIX_MARKER } from "./form-fix";
2
+
3
+ export const PFR_FORM_TAG = "pfr-form";
4
+
5
+ const PROMPT_BODY = `You can render interactive forms, dashboards, parameter inputs, and approval cards by outputting a JSON specification inside a fenced code block with the tag \`\`\`pfr-form.
6
+
7
+ Use this format whenever you need to:
8
+ 1. Collect input or configuration from the user (text, choices, toggles).
9
+ 2. Ask for user confirmation or approval before running an action.
10
+ 3. Present metrics, status progress, or structured data tables clearly.
11
+
12
+ ### Protocol Specification
13
+ The JSON is a nested component tree starting with a root node (usually a "Card").
14
+ Available component types:
15
+ - **Card**: { "type": "Card", "title"?: string, "description"?: string, "children": [...] }
16
+ - **Stack**: { "type": "Stack", "direction"?: "vertical"|"horizontal", "children": [...] }
17
+ - **Heading**: { "type": "Heading", "text": string }
18
+ - **Text**: { "type": "Text", "text": string, "muted"?: boolean }
19
+ - **Divider**: { "type": "Divider" }
20
+ - **Metric**: { "type": "Metric", "label": string, "value": string|number }
21
+ - **Badge**: { "type": "Badge", "text": string, "tone"?: "neutral"|"info"|"success"|"warning"|"danger" }
22
+ - **Alert**: { "type": "Alert", "title"?: string, "text": string, "tone"?: "neutral"|"info"|"success"|"warning"|"danger" }
23
+ - **Progress**: { "type": "Progress", "label": string, "value": number (0-100) }
24
+ - **Table**: { "type": "Table", "columns": [{ "key": string, "label": string }], "rows": [{ [key: string]: any }] }
25
+ - **Input**: { "type": "Input", "id": string, "label": string, "placeholder"?: string, "defaultValue"?: string|number, "inputType"?: "text"|"number"|"integer"|"email"|"password"|"url", "multiline"?: boolean, "required"?: boolean, "min"?: number, "max"?: number, "minLength"?: number, "maxLength"?: number, "pattern"?: string }
26
+ - **Select**: { "type": "Select", "id": string, "label": string, "options": [{ "label": string, "value": string }], "defaultValue"?: string, "required"?: boolean }
27
+ - **MultiSelect**: { "type": "MultiSelect", "id": string, "label": string, "placeholder"?: string, "options": [{ "label": string, "value": string }], "defaultValue"?: string[], "required"?: boolean, "min"?: number, "max"?: number }
28
+ - **FilePicker**: { "type": "FilePicker", "id": string, "label": string, "placeholder"?: string, "pickerType"?: "file"|"folder", "extensions"?: string[], "defaultValue"?: string, "required"?: boolean }
29
+ * Unified in-app modal file and folder picker with quick shortcuts (Project, Home, Desktop, Downloads, OneDrive). Returns project-relative path if inside workspace, or host absolute path if outside. Zero cold start latency, foreground-guaranteed. When asking for specific file types (e.g. mp4, mov, png, json), ALWAYS specify "extensions": ["mp4"].
30
+ - **Toggle**: { "type": "Toggle", "id": string, "label": string, "defaultValue"?: boolean, "required"?: boolean }
31
+ - **Button**: { "type": "Button", "label": string, "variant"?: "primary"|"secondary"|"danger", "action": "submit"|"reset", "message"?: string }
32
+
33
+ ### Important Rules:
34
+ 1. Always give input fields (\`Input\`, \`Select\`, \`MultiSelect\`, \`FilePicker\`, \`Toggle\`) a unique \`id\`.
35
+ 2. When filtering files by extension, use \`"extensions": ["mp4"]\` (or comma-separated string, leading dot optional).
36
+ 3. When the user clicks a \`Button\` with \`action: "submit"\`, the client automatically sends the button's \`message\` along with all field values back to you.
37
+ 4. Keep conversational text outside the code fence. The code block should only contain valid JSON.
38
+ 5. If you receive a message starting with \`${PFR_FIX_MARKER}\`, a previous pfr-form block failed validation. Re-output only the corrected, complete pfr-form block(s), addressing every listed issue, with valid JSON and no comments.
39
+
40
+ ### Example:
41
+ \`\`\`pfr-form
42
+ {
43
+ "type": "Card",
44
+ "title": "Deployment Confirmation",
45
+ "description": "Please review settings before releasing.",
46
+ "children": [
47
+ {
48
+ "type": "Metric",
49
+ "label": "Target Environment",
50
+ "value": "Production"
51
+ },
52
+ {
53
+ "type": "Input",
54
+ "id": "release_tag",
55
+ "label": "Release Tag",
56
+ "placeholder": "e.g., v1.0.4",
57
+ "required": true
58
+ },
59
+ {
60
+ "type": "Select",
61
+ "id": "strategy",
62
+ "label": "Deploy Strategy",
63
+ "options": [
64
+ { "label": "Rolling Update", "value": "rolling" },
65
+ { "label": "Canary (10%)", "value": "canary" }
66
+ ],
67
+ "defaultValue": "rolling"
68
+ },
69
+ {
70
+ "type": "Button",
71
+ "label": "Confirm & Deploy",
72
+ "variant": "primary",
73
+ "action": "submit",
74
+ "message": "Start deployment with the selected parameters."
75
+ }
76
+ ]
77
+ }
78
+ \`\`\`
79
+ `.trim();
80
+
81
+ /** Appended when the "prefer forms" setting is on; the default prompt only says the agent *can* use pfr-form. */
82
+ const PREFER_FORMS_INSTRUCTION = `
83
+ ### Required usage:
84
+ Prefer pfr-form over plain text for interacting with the user:
85
+ - When you need anything from the user — a choice, a parameter, a value, or a go/no-go confirmation — you MUST ask with an \`pfr-form\` block, not plain-text questions.
86
+ - When presenting results, status, or structured data (metrics, progress, tables, warnings), use the matching components (Metric, Progress, Table, Alert, Badge) where they make the result clearer.
87
+ Plain text is fine for ordinary explanation and short conversational replies.
88
+ `.trim();
89
+
90
+ export function buildPromptInstruction(options: { preferForms?: boolean } = {}): string {
91
+ const parts = [PROMPT_BODY];
92
+ if (options.preferForms) parts.push(PREFER_FORMS_INSTRUCTION);
93
+ return `[paseo-form-render]\n${parts.join("\n\n")}\n[/paseo-form-render]`;
94
+ }
95
+
96
+ export const PFR_PROMPT_INSTRUCTION = buildPromptInstruction();
@@ -0,0 +1,330 @@
1
+ import { z } from "zod";
2
+
3
+ // Basic field options
4
+ export const OptionSchema = z.object({
5
+ label: z.string(),
6
+ value: z.string(),
7
+ });
8
+ export type Option = z.infer<typeof OptionSchema>;
9
+
10
+ // Table column schema
11
+ export const TableColumnSchema = z.object({
12
+ key: z.string(),
13
+ label: z.string(),
14
+ });
15
+ export type TableColumn = z.infer<typeof TableColumnSchema>;
16
+
17
+ // Tone variants for Alert and Badge
18
+ export const ToneSchema = z.enum(["neutral", "info", "success", "warning", "danger"]);
19
+ export type Tone = z.infer<typeof ToneSchema>;
20
+
21
+ // Node base definition
22
+ export type PfrNode =
23
+ | CardNode
24
+ | StackNode
25
+ | HeadingNode
26
+ | TextNode
27
+ | DividerNode
28
+ | MetricNode
29
+ | BadgeNode
30
+ | AlertNode
31
+ | ProgressNode
32
+ | TableNode
33
+ | InputNode
34
+ | SelectNode
35
+ | MultiSelectNode
36
+ | ToggleNode
37
+ | FilePickerNode
38
+ | ButtonNode;
39
+
40
+ /** Every valid node `type`. The compile-time check below fails if a node type is missing from this list. */
41
+ export const PFR_NODE_TYPES = [
42
+ "Card",
43
+ "Stack",
44
+ "Heading",
45
+ "Text",
46
+ "Divider",
47
+ "Metric",
48
+ "Badge",
49
+ "Alert",
50
+ "Progress",
51
+ "Table",
52
+ "Input",
53
+ "Select",
54
+ "MultiSelect",
55
+ "Toggle",
56
+ "FilePicker",
57
+ "Button",
58
+ ] as const satisfies readonly PfrNode["type"][];
59
+ type MissingNodeTypes = Exclude<PfrNode["type"], (typeof PFR_NODE_TYPES)[number]>;
60
+ const _allNodeTypesListed: [MissingNodeTypes] extends [never] ? true : never = true;
61
+ void _allNodeTypesListed;
62
+
63
+ /** Node types that collect a value and therefore need a unique `id`. */
64
+ export const PFR_FIELD_TYPES = ["Input", "Select", "MultiSelect", "Toggle", "FilePicker"] as const;
65
+
66
+ export interface CardNode {
67
+ type: "Card";
68
+ title?: string;
69
+ description?: string;
70
+ children: PfrNode[];
71
+ }
72
+
73
+ export interface StackNode {
74
+ type: "Stack";
75
+ direction?: "vertical" | "horizontal";
76
+ children: PfrNode[];
77
+ }
78
+
79
+ export interface HeadingNode {
80
+ type: "Heading";
81
+ text: string;
82
+ }
83
+
84
+ export interface TextNode {
85
+ type: "Text";
86
+ text: string;
87
+ muted?: boolean;
88
+ }
89
+
90
+ export interface DividerNode {
91
+ type: "Divider";
92
+ }
93
+
94
+ export interface MetricNode {
95
+ type: "Metric";
96
+ label: string;
97
+ value: string | number;
98
+ }
99
+
100
+ export interface BadgeNode {
101
+ type: "Badge";
102
+ text: string;
103
+ tone?: Tone;
104
+ }
105
+
106
+ export interface AlertNode {
107
+ type: "Alert";
108
+ title?: string;
109
+ text: string;
110
+ tone?: Tone;
111
+ }
112
+
113
+ export interface ProgressNode {
114
+ type: "Progress";
115
+ label: string;
116
+ value: number; // 0 - 100
117
+ }
118
+
119
+ export interface TableNode {
120
+ type: "Table";
121
+ columns: TableColumn[];
122
+ rows: Record<string, string | number | boolean | null>[];
123
+ }
124
+
125
+ export type InputType = "text" | "number" | "integer" | "email" | "password" | "url";
126
+
127
+ export interface InputNode {
128
+ type: "Input";
129
+ id: string;
130
+ label: string;
131
+ placeholder?: string;
132
+ defaultValue?: string | number;
133
+ inputType?: InputType;
134
+ multiline?: boolean;
135
+ required?: boolean;
136
+ min?: number;
137
+ max?: number;
138
+ minLength?: number;
139
+ maxLength?: number;
140
+ pattern?: string;
141
+ }
142
+
143
+ export interface SelectNode {
144
+ type: "Select";
145
+ id: string;
146
+ label: string;
147
+ options: Option[];
148
+ defaultValue?: string;
149
+ required?: boolean;
150
+ }
151
+
152
+ export interface MultiSelectNode {
153
+ type: "MultiSelect";
154
+ id: string;
155
+ label: string;
156
+ placeholder?: string;
157
+ options: Option[];
158
+ defaultValue?: string[];
159
+ required?: boolean;
160
+ min?: number;
161
+ max?: number;
162
+ }
163
+
164
+ export interface ToggleNode {
165
+ type: "Toggle";
166
+ id: string;
167
+ label: string;
168
+ defaultValue?: boolean;
169
+ required?: boolean;
170
+ }
171
+
172
+ export function normalizeExtensions(
173
+ extensions?: string[] | string,
174
+ extension?: string,
175
+ ): string[] | undefined {
176
+ const rawList: string[] = [];
177
+ if (Array.isArray(extensions)) {
178
+ rawList.push(...extensions);
179
+ } else if (typeof extensions === "string") {
180
+ rawList.push(...extensions.split(","));
181
+ }
182
+ if (typeof extension === "string") {
183
+ rawList.push(...extension.split(","));
184
+ }
185
+
186
+ const cleaned = rawList
187
+ .map((e) => e.trim().toLowerCase().replace(/^\./, ""))
188
+ .filter(Boolean);
189
+
190
+ return cleaned.length > 0 ? Array.from(new Set(cleaned)) : undefined;
191
+ }
192
+
193
+ export interface FilePickerNode {
194
+ type: "FilePicker";
195
+ id: string;
196
+ label: string;
197
+ pickerType?: "file" | "folder";
198
+ scope?: "workspace" | "global";
199
+ placeholder?: string;
200
+ defaultValue?: string;
201
+ required?: boolean;
202
+ extensions?: string[] | string;
203
+ extension?: string;
204
+ }
205
+
206
+ export interface ButtonNode {
207
+ type: "Button";
208
+ label: string;
209
+ variant?: "primary" | "secondary" | "danger";
210
+ action: "submit" | "reset";
211
+ message?: string; // Prompt/message returned to agent when submitted
212
+ }
213
+
214
+ // Recursive Zod Schema for validation
215
+ export const PfrNodeSchema: z.ZodType<PfrNode> = z.lazy(() =>
216
+ z.discriminatedUnion("type", [
217
+ z.object({
218
+ type: z.literal("Card"),
219
+ title: z.string().optional(),
220
+ description: z.string().optional(),
221
+ children: z.array(PfrNodeSchema),
222
+ }),
223
+ z.object({
224
+ type: z.literal("Stack"),
225
+ direction: z.enum(["vertical", "horizontal"]).optional(),
226
+ children: z.array(PfrNodeSchema),
227
+ }),
228
+ z.object({
229
+ type: z.literal("Heading"),
230
+ text: z.string(),
231
+ }),
232
+ z.object({
233
+ type: z.literal("Text"),
234
+ text: z.string(),
235
+ muted: z.boolean().optional(),
236
+ }),
237
+ z.object({
238
+ type: z.literal("Divider"),
239
+ }),
240
+ z.object({
241
+ type: z.literal("Metric"),
242
+ label: z.string(),
243
+ value: z.union([z.string(), z.number()]),
244
+ }),
245
+ z.object({
246
+ type: z.literal("Badge"),
247
+ text: z.string(),
248
+ tone: ToneSchema.optional(),
249
+ }),
250
+ z.object({
251
+ type: z.literal("Alert"),
252
+ title: z.string().optional(),
253
+ text: z.string(),
254
+ tone: ToneSchema.optional(),
255
+ }),
256
+ z.object({
257
+ type: z.literal("Progress"),
258
+ label: z.string(),
259
+ value: z.number().min(0).max(100),
260
+ }),
261
+ z.object({
262
+ type: z.literal("Table"),
263
+ columns: z.array(TableColumnSchema).min(1),
264
+ rows: z.array(z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))),
265
+ }),
266
+ z.object({
267
+ type: z.literal("Input"),
268
+ id: z.string().min(1),
269
+ label: z.string(),
270
+ placeholder: z.string().optional(),
271
+ defaultValue: z.union([z.string(), z.number()]).optional(),
272
+ inputType: z.enum(["text", "number", "integer", "email", "password", "url"]).optional(),
273
+ multiline: z.boolean().optional(),
274
+ required: z.boolean().optional(),
275
+ min: z.number().optional(),
276
+ max: z.number().optional(),
277
+ minLength: z.number().int().nonnegative().optional(),
278
+ maxLength: z.number().int().nonnegative().optional(),
279
+ pattern: z.string().optional(),
280
+ }),
281
+ z.object({
282
+ type: z.literal("Select"),
283
+ id: z.string().min(1),
284
+ label: z.string(),
285
+ options: z.array(OptionSchema).min(1),
286
+ defaultValue: z.string().optional(),
287
+ required: z.boolean().optional(),
288
+ }),
289
+ z.object({
290
+ type: z.literal("MultiSelect"),
291
+ id: z.string().min(1),
292
+ label: z.string(),
293
+ placeholder: z.string().optional(),
294
+ options: z.array(OptionSchema).min(1),
295
+ defaultValue: z.array(z.string()).optional(),
296
+ required: z.boolean().optional(),
297
+ min: z.number().int().nonnegative().optional(),
298
+ max: z.number().int().nonnegative().optional(),
299
+ }),
300
+ z.object({
301
+ type: z.literal("Toggle"),
302
+ id: z.string().min(1),
303
+ label: z.string(),
304
+ defaultValue: z.boolean().optional(),
305
+ required: z.boolean().optional(),
306
+ }),
307
+ z.object({
308
+ type: z.literal("FilePicker"),
309
+ id: z.string().min(1),
310
+ label: z.string(),
311
+ pickerType: z.enum(["file", "folder"]).optional(),
312
+ scope: z.enum(["workspace", "global"]).optional(),
313
+ placeholder: z.string().optional(),
314
+ defaultValue: z.string().optional(),
315
+ required: z.boolean().optional(),
316
+ extensions: z.union([z.string(), z.array(z.string())]).optional(),
317
+ extension: z.string().optional(),
318
+ }),
319
+ z.object({
320
+ type: z.literal("Button"),
321
+ label: z.string(),
322
+ variant: z.enum(["primary", "secondary", "danger"]).optional(),
323
+ action: z.enum(["submit", "reset"]),
324
+ message: z.string().optional(),
325
+ }),
326
+ ]),
327
+ );
328
+
329
+ export const PfrFormSchema = PfrNodeSchema;
330
+ export type PfrForm = PfrNode;
@@ -0,0 +1,18 @@
1
+ import { defineSettings } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+
4
+ export const MAX_AUTO_FIX_RETRIES = 5;
5
+ export const DEFAULT_AUTO_FIX_RETRIES = 2;
6
+ export const DEFAULT_PREFER_FORMS = true;
7
+
8
+ export const pfrSettings = defineSettings({
9
+ id: "paseo-form-render",
10
+ scope: "host",
11
+ version: 1,
12
+ schema: z.object({
13
+ /** How many times the agent is asked to repair an invalid pfr-form block in a row. 0 disables auto-fix. */
14
+ autoFixMaxRetries: z.number().int().min(0).max(MAX_AUTO_FIX_RETRIES).default(DEFAULT_AUTO_FIX_RETRIES),
15
+ /** When on, the injected prompt requires agents to ask for user input with pfr-form instead of plain text. */
16
+ preferForms: z.boolean().default(DEFAULT_PREFER_FORMS),
17
+ }),
18
+ });