@elizaos/plugin-form 2.0.3-beta.6 → 2.0.3-beta.7

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.
Files changed (57) hide show
  1. package/dist/actions/form.d.ts +31 -0
  2. package/dist/actions/form.d.ts.map +1 -0
  3. package/dist/actions/form.js +187 -0
  4. package/dist/actions/form.js.map +1 -0
  5. package/dist/builder.d.ts +320 -0
  6. package/dist/builder.d.ts.map +1 -0
  7. package/dist/builder.js +458 -0
  8. package/dist/builder.js.map +1 -0
  9. package/dist/builtins.d.ts +128 -0
  10. package/dist/builtins.d.ts.map +1 -0
  11. package/dist/builtins.js +233 -0
  12. package/dist/builtins.js.map +1 -0
  13. package/dist/defaults.d.ts +95 -0
  14. package/dist/defaults.d.ts.map +1 -0
  15. package/dist/defaults.js +79 -0
  16. package/dist/defaults.js.map +1 -0
  17. package/dist/evaluators/extractor.d.ts +28 -0
  18. package/dist/evaluators/extractor.d.ts.map +1 -0
  19. package/dist/evaluators/extractor.js +251 -0
  20. package/dist/evaluators/extractor.js.map +1 -0
  21. package/dist/extraction.d.ts +55 -0
  22. package/dist/extraction.d.ts.map +1 -0
  23. package/dist/extraction.js +347 -0
  24. package/dist/extraction.js.map +1 -0
  25. package/dist/index.d.ts +31 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +149 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/providers/context.d.ts +56 -0
  30. package/dist/providers/context.d.ts.map +1 -0
  31. package/dist/providers/context.js +204 -0
  32. package/dist/providers/context.js.map +1 -0
  33. package/dist/service.d.ts +402 -0
  34. package/dist/service.d.ts.map +1 -0
  35. package/dist/service.js +1199 -0
  36. package/dist/service.js.map +1 -0
  37. package/dist/storage.d.ts +228 -0
  38. package/dist/storage.d.ts.map +1 -0
  39. package/dist/storage.js +255 -0
  40. package/dist/storage.js.map +1 -0
  41. package/dist/template.d.ts +10 -0
  42. package/dist/template.d.ts.map +1 -0
  43. package/dist/template.js +60 -0
  44. package/dist/template.js.map +1 -0
  45. package/dist/ttl.d.ts +144 -0
  46. package/dist/ttl.d.ts.map +1 -0
  47. package/dist/ttl.js +85 -0
  48. package/dist/ttl.js.map +1 -0
  49. package/dist/types.d.ts +1213 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/types.js +39 -0
  52. package/dist/types.js.map +1 -0
  53. package/dist/validation.d.ts +156 -0
  54. package/dist/validation.d.ts.map +1 -0
  55. package/dist/validation.js +289 -0
  56. package/dist/validation.js.map +1 -0
  57. package/package.json +3 -3
@@ -0,0 +1,1199 @@
1
+ import {
2
+ logger,
3
+ Service
4
+ } from "@elizaos/core";
5
+ import { v4 as uuidv4 } from "uuid";
6
+ import { registerBuiltinTypes } from "./builtins.js";
7
+ import {
8
+ getAutofillData,
9
+ getSessionById,
10
+ saveAutofillData,
11
+ saveSubmission,
12
+ getActiveSession as storageGetActiveSession,
13
+ getAllActiveSessions as storageGetAllActiveSessions,
14
+ getStashedSessions as storageGetStashedSessions,
15
+ getSubmissions as storageGetSubmissions,
16
+ saveSession as storageSaveSession
17
+ } from "./storage.js";
18
+ import { FORM_CONTROL_DEFAULTS, FORM_DEFINITION_DEFAULTS } from "./types.js";
19
+ import { formatValue, validateField } from "./validation.js";
20
+ const UNSAFE_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
21
+ function assertSafeObjectKey(kind, key) {
22
+ if (typeof key !== "string" || key.trim() === "") {
23
+ throw new Error(`${kind} must be a non-empty string`);
24
+ }
25
+ if (UNSAFE_OBJECT_KEYS.has(key)) {
26
+ throw new Error(`${kind} uses unsafe object key: ${key}`);
27
+ }
28
+ }
29
+ function createValueMap() {
30
+ return /* @__PURE__ */ Object.create(null);
31
+ }
32
+ class FormService extends Service {
33
+ /** Service type identifier for runtime.getService() */
34
+ static serviceType = "FORM";
35
+ /** Description shown in agent capabilities */
36
+ capabilityDescription = "Manages conversational forms for data collection";
37
+ /**
38
+ * In-memory storage of form definitions.
39
+ *
40
+ * WHY Map:
41
+ * - O(1) lookup by ID
42
+ * - Forms are static after registration
43
+ * - No persistence needed (re-registered on startup)
44
+ */
45
+ forms = /* @__PURE__ */ new Map();
46
+ /**
47
+ * Control type registry.
48
+ *
49
+ * Built-in types are registered on start.
50
+ * Plugins can register custom types.
51
+ */
52
+ controlTypes = /* @__PURE__ */ new Map();
53
+ /**
54
+ * Start the FormService
55
+ */
56
+ static async start(runtime) {
57
+ const service = new FormService(runtime);
58
+ registerBuiltinTypes(
59
+ (type, options) => service.registerControlType(type, options)
60
+ );
61
+ logger.info("[FormService] Started with built-in types");
62
+ return service;
63
+ }
64
+ /**
65
+ * Stop the FormService
66
+ */
67
+ async stop() {
68
+ logger.info("[FormService] Stopped");
69
+ }
70
+ // ============================================================================
71
+ // FORM DEFINITION MANAGEMENT
72
+ // ============================================================================
73
+ /**
74
+ * Register a form definition
75
+ */
76
+ registerForm(definition) {
77
+ assertSafeObjectKey("Form id", definition?.id);
78
+ if (!Array.isArray(definition.controls)) {
79
+ throw new Error("Form controls must be an array");
80
+ }
81
+ const controlKeys = /* @__PURE__ */ new Set();
82
+ for (const control of definition.controls) {
83
+ assertSafeObjectKey("Control key", control?.key);
84
+ if (controlKeys.has(control.key)) {
85
+ throw new Error(`Duplicate control key: ${control.key}`);
86
+ }
87
+ controlKeys.add(control.key);
88
+ if (control.dbbind !== void 0) {
89
+ assertSafeObjectKey("Control dbbind", control.dbbind);
90
+ }
91
+ }
92
+ const form = {
93
+ ...definition,
94
+ version: definition.version ?? FORM_DEFINITION_DEFAULTS.version,
95
+ status: definition.status ?? FORM_DEFINITION_DEFAULTS.status,
96
+ ux: { ...FORM_DEFINITION_DEFAULTS.ux, ...definition.ux },
97
+ ttl: { ...FORM_DEFINITION_DEFAULTS.ttl, ...definition.ttl },
98
+ nudge: { ...FORM_DEFINITION_DEFAULTS.nudge, ...definition.nudge },
99
+ debug: definition.debug ?? FORM_DEFINITION_DEFAULTS.debug,
100
+ controls: definition.controls.map((control) => ({
101
+ ...control,
102
+ type: control.type || FORM_CONTROL_DEFAULTS.type,
103
+ required: control.required ?? FORM_CONTROL_DEFAULTS.required,
104
+ confirmThreshold: control.confirmThreshold ?? FORM_CONTROL_DEFAULTS.confirmThreshold,
105
+ label: control.label || prettify(control.key)
106
+ }))
107
+ };
108
+ this.forms.set(form.id, form);
109
+ logger.debug(`[FormService] Registered form: ${form.id}`);
110
+ }
111
+ /**
112
+ * Get a form definition by ID
113
+ */
114
+ getForm(formId) {
115
+ return this.forms.get(formId);
116
+ }
117
+ /**
118
+ * Get all registered forms
119
+ */
120
+ listForms() {
121
+ return Array.from(this.forms.values());
122
+ }
123
+ // ============================================================================
124
+ // CONTROL TYPE REGISTRY
125
+ // ============================================================================
126
+ /**
127
+ * Register a control type.
128
+ *
129
+ * Control types define how a field type behaves:
130
+ * - Simple types: validate/parse/format
131
+ * - Composite types: have subcontrols
132
+ * - External types: have activate/deactivate for async processes
133
+ *
134
+ * Built-in types (text, number, email, etc.) are registered at startup
135
+ * and protected from override unless explicitly allowed.
136
+ *
137
+ * @param type - The ControlType definition
138
+ * @param options - Registration options
139
+ * @param options.allowOverride - Allow overriding built-in types (default: false)
140
+ *
141
+ * @example
142
+ * ```typescript
143
+ * formService.registerControlType({
144
+ * id: 'payment',
145
+ * getSubControls: () => [
146
+ * { key: 'amount', type: 'number', label: 'Amount', required: true },
147
+ * { key: 'currency', type: 'select', label: 'Currency', required: true },
148
+ * ],
149
+ * activate: async (ctx) => {
150
+ * const paymentService = ctx.runtime.getService('PAYMENT');
151
+ * return paymentService.createPending(ctx.subValues);
152
+ * },
153
+ * });
154
+ * ```
155
+ */
156
+ registerControlType(type, options) {
157
+ const existing = this.controlTypes.get(type.id);
158
+ if (existing) {
159
+ if (existing.builtin && !options?.allowOverride) {
160
+ logger.warn(
161
+ `[FormService] Cannot override builtin type '${type.id}' without allowOverride: true`
162
+ );
163
+ return;
164
+ }
165
+ logger.warn(`[FormService] Overriding control type: ${type.id}`);
166
+ }
167
+ this.controlTypes.set(type.id, type);
168
+ logger.debug(`[FormService] Registered control type: ${type.id}`);
169
+ }
170
+ /**
171
+ * Get a control type by ID.
172
+ *
173
+ * @param typeId - The type ID to look up
174
+ * @returns The ControlType or undefined if not found
175
+ */
176
+ getControlType(typeId) {
177
+ return this.controlTypes.get(typeId);
178
+ }
179
+ /**
180
+ * List all registered control types.
181
+ *
182
+ * @returns Array of all registered ControlTypes
183
+ */
184
+ listControlTypes() {
185
+ return Array.from(this.controlTypes.values());
186
+ }
187
+ /**
188
+ * Check if a control type has subcontrols.
189
+ *
190
+ * @param typeId - The type ID to check
191
+ * @returns true if the type has getSubControls method
192
+ */
193
+ isCompositeType(typeId) {
194
+ const type = this.controlTypes.get(typeId);
195
+ return !!type?.getSubControls;
196
+ }
197
+ /**
198
+ * Check if a control type is an external type.
199
+ *
200
+ * @param typeId - The type ID to check
201
+ * @returns true if the type has activate method
202
+ */
203
+ isExternalType(typeId) {
204
+ const type = this.controlTypes.get(typeId);
205
+ return !!type?.activate;
206
+ }
207
+ /**
208
+ * Get subcontrols for a composite type.
209
+ *
210
+ * @param control - The parent control
211
+ * @returns Array of subcontrols or empty array if not composite
212
+ */
213
+ getSubControls(control) {
214
+ const type = this.controlTypes.get(control.type);
215
+ if (!type?.getSubControls) {
216
+ return [];
217
+ }
218
+ return type.getSubControls(control, this.runtime);
219
+ }
220
+ // ============================================================================
221
+ // SESSION MANAGEMENT
222
+ // ============================================================================
223
+ /**
224
+ * Start a new form session
225
+ */
226
+ async startSession(formId, entityId, roomId, options) {
227
+ const form = this.getForm(formId);
228
+ if (!form) {
229
+ throw new Error(`Form not found: ${formId}`);
230
+ }
231
+ const existing = await storageGetActiveSession(
232
+ this.runtime,
233
+ entityId,
234
+ roomId
235
+ );
236
+ if (existing) {
237
+ throw new Error(
238
+ `Active session already exists for this user/room: ${existing.id}`
239
+ );
240
+ }
241
+ const now = Date.now();
242
+ const fields = createValueMap();
243
+ for (const control of form.controls) {
244
+ if (options?.initialValues?.[control.key] !== void 0) {
245
+ const validation = validateField(
246
+ options.initialValues[control.key],
247
+ control
248
+ );
249
+ fields[control.key] = {
250
+ status: validation.valid ? "filled" : "invalid",
251
+ value: options.initialValues[control.key],
252
+ source: "manual",
253
+ updatedAt: now,
254
+ error: validation.error
255
+ };
256
+ } else if (control.defaultValue !== void 0) {
257
+ const validation = validateField(control.defaultValue, control);
258
+ fields[control.key] = {
259
+ status: validation.valid ? "filled" : "invalid",
260
+ value: control.defaultValue,
261
+ source: "default",
262
+ updatedAt: now,
263
+ error: validation.error
264
+ };
265
+ } else {
266
+ fields[control.key] = { status: "empty" };
267
+ }
268
+ }
269
+ const ttlDays = form.ttl?.minDays ?? 14;
270
+ const expiresAt = now + ttlDays * 24 * 60 * 60 * 1e3;
271
+ const session = {
272
+ id: uuidv4(),
273
+ formId,
274
+ formVersion: form.version,
275
+ entityId,
276
+ roomId,
277
+ status: "active",
278
+ fields,
279
+ history: [],
280
+ context: options?.context,
281
+ locale: options?.locale,
282
+ effort: {
283
+ interactionCount: 0,
284
+ timeSpentMs: 0,
285
+ firstInteractionAt: now,
286
+ lastInteractionAt: now
287
+ },
288
+ expiresAt,
289
+ createdAt: now,
290
+ updatedAt: now
291
+ };
292
+ await storageSaveSession(this.runtime, session);
293
+ if (form.hooks?.onStart) {
294
+ await this.executeHook(session, "onStart");
295
+ }
296
+ logger.debug(
297
+ `[FormService] Started session ${session.id} for form ${formId}`
298
+ );
299
+ return session;
300
+ }
301
+ /**
302
+ * Get active session for entity in room
303
+ */
304
+ async getActiveSession(entityId, roomId) {
305
+ return storageGetActiveSession(this.runtime, entityId, roomId);
306
+ }
307
+ /**
308
+ * Get all active sessions for entity (across all rooms)
309
+ */
310
+ async getAllActiveSessions(entityId) {
311
+ return storageGetAllActiveSessions(this.runtime, entityId);
312
+ }
313
+ /**
314
+ * Get stashed sessions for entity
315
+ */
316
+ async getStashedSessions(entityId) {
317
+ return storageGetStashedSessions(this.runtime, entityId);
318
+ }
319
+ /**
320
+ * Save a session
321
+ */
322
+ async saveSession(session) {
323
+ session.updatedAt = Date.now();
324
+ await storageSaveSession(this.runtime, session);
325
+ }
326
+ // ============================================================================
327
+ // FIELD UPDATES
328
+ // ============================================================================
329
+ /**
330
+ * Update a field value
331
+ */
332
+ async updateField(sessionId, entityId, field, value, confidence, source, messageId) {
333
+ const session = await getSessionById(this.runtime, entityId, sessionId);
334
+ if (!session) {
335
+ throw new Error(`Session not found: ${sessionId}`);
336
+ }
337
+ const form = this.getForm(session.formId);
338
+ if (!form) {
339
+ throw new Error(`Form not found: ${session.formId}`);
340
+ }
341
+ const control = form.controls.find((c) => c.key === field);
342
+ if (!control) {
343
+ throw new Error(`Field not found: ${field}`);
344
+ }
345
+ const oldValue = session.fields[field]?.value;
346
+ const validation = validateField(value, control);
347
+ let status;
348
+ if (!validation.valid) {
349
+ status = "invalid";
350
+ } else if (confidence < (control.confirmThreshold ?? 0.8)) {
351
+ status = "uncertain";
352
+ } else {
353
+ status = "filled";
354
+ }
355
+ const now = Date.now();
356
+ if (oldValue !== void 0) {
357
+ const historyEntry = {
358
+ field,
359
+ oldValue,
360
+ newValue: value,
361
+ timestamp: now
362
+ };
363
+ session.history.push(historyEntry);
364
+ const maxUndo = form.ux?.maxUndoSteps ?? 5;
365
+ if (session.history.length > maxUndo) {
366
+ session.history = session.history.slice(-maxUndo);
367
+ }
368
+ }
369
+ session.fields[field] = {
370
+ status,
371
+ value,
372
+ confidence,
373
+ source,
374
+ messageId,
375
+ updatedAt: now,
376
+ error: !validation.valid ? validation.error : void 0
377
+ };
378
+ session.effort.interactionCount++;
379
+ session.effort.lastInteractionAt = now;
380
+ session.effort.timeSpentMs = now - session.effort.firstInteractionAt;
381
+ session.expiresAt = this.calculateTTL(session);
382
+ const allRequiredFilled = this.checkAllRequiredFilled(session, form);
383
+ if (allRequiredFilled && session.status === "active") {
384
+ session.status = "ready";
385
+ if (form.hooks?.onReady) {
386
+ await this.executeHook(session, "onReady");
387
+ }
388
+ }
389
+ session.updatedAt = now;
390
+ await storageSaveSession(this.runtime, session);
391
+ if (form.hooks?.onFieldChange) {
392
+ const hookPayload = { field, value };
393
+ if (oldValue !== void 0) {
394
+ hookPayload.oldValue = oldValue;
395
+ }
396
+ await this.executeHook(session, "onFieldChange", hookPayload);
397
+ }
398
+ }
399
+ /**
400
+ * Undo the last field change
401
+ */
402
+ async undoLastChange(sessionId, entityId) {
403
+ const session = await getSessionById(this.runtime, entityId, sessionId);
404
+ if (!session) {
405
+ throw new Error(`Session not found: ${sessionId}`);
406
+ }
407
+ const form = this.getForm(session.formId);
408
+ if (!form?.ux?.allowUndo) {
409
+ return null;
410
+ }
411
+ const lastChange = session.history.pop();
412
+ if (!lastChange) {
413
+ return null;
414
+ }
415
+ if (lastChange.oldValue !== void 0) {
416
+ session.fields[lastChange.field] = {
417
+ status: "filled",
418
+ value: lastChange.oldValue,
419
+ source: "correction",
420
+ updatedAt: Date.now()
421
+ };
422
+ } else {
423
+ session.fields[lastChange.field] = { status: "empty" };
424
+ }
425
+ session.updatedAt = Date.now();
426
+ await storageSaveSession(this.runtime, session);
427
+ return { field: lastChange.field, restoredValue: lastChange.oldValue };
428
+ }
429
+ /**
430
+ * Skip an optional field
431
+ */
432
+ async skipField(sessionId, entityId, field) {
433
+ const session = await getSessionById(this.runtime, entityId, sessionId);
434
+ if (!session) {
435
+ throw new Error(`Session not found: ${sessionId}`);
436
+ }
437
+ const form = this.getForm(session.formId);
438
+ if (!form?.ux?.allowSkip) {
439
+ return false;
440
+ }
441
+ const control = form.controls.find((c) => c.key === field);
442
+ if (!control) {
443
+ return false;
444
+ }
445
+ if (control.required) {
446
+ return false;
447
+ }
448
+ session.fields[field] = {
449
+ status: "skipped",
450
+ updatedAt: Date.now()
451
+ };
452
+ session.updatedAt = Date.now();
453
+ await storageSaveSession(this.runtime, session);
454
+ return true;
455
+ }
456
+ /**
457
+ * Confirm an uncertain field value
458
+ */
459
+ async confirmField(sessionId, entityId, field, accepted) {
460
+ const session = await getSessionById(this.runtime, entityId, sessionId);
461
+ if (!session) {
462
+ throw new Error(`Session not found: ${sessionId}`);
463
+ }
464
+ const fieldState = session.fields[field];
465
+ if (!fieldState || fieldState.status !== "uncertain") {
466
+ return;
467
+ }
468
+ const now = Date.now();
469
+ if (accepted) {
470
+ fieldState.status = "filled";
471
+ fieldState.confirmedAt = now;
472
+ } else {
473
+ fieldState.status = "empty";
474
+ fieldState.value = void 0;
475
+ fieldState.confidence = void 0;
476
+ }
477
+ fieldState.updatedAt = now;
478
+ session.updatedAt = now;
479
+ await storageSaveSession(this.runtime, session);
480
+ }
481
+ // ============================================================================
482
+ // SUBFIELD UPDATES (for composite types)
483
+ // ============================================================================
484
+ /**
485
+ * Update a subfield value for a composite control type.
486
+ *
487
+ * Composite types (like payment, address) have subcontrols that must
488
+ * all be filled before the parent field is complete.
489
+ *
490
+ * WHY separate from updateField:
491
+ * - Subfields are stored in fieldState.subFields, not session.fields
492
+ * - Parent field status depends on all subfields being filled
493
+ * - Allows tracking subfield confidence/status independently
494
+ *
495
+ * @param sessionId - The session ID
496
+ * @param entityId - The entity/user ID
497
+ * @param parentField - The parent control key (e.g., "payment")
498
+ * @param subField - The subcontrol key (e.g., "amount")
499
+ * @param value - The extracted value
500
+ * @param confidence - LLM confidence (0-1)
501
+ * @param messageId - Optional message ID for audit
502
+ */
503
+ async updateSubField(sessionId, entityId, parentField, subField, value, confidence, messageId) {
504
+ const session = await getSessionById(this.runtime, entityId, sessionId);
505
+ if (!session) {
506
+ throw new Error(`Session not found: ${sessionId}`);
507
+ }
508
+ const form = this.getForm(session.formId);
509
+ if (!form) {
510
+ throw new Error(`Form not found: ${session.formId}`);
511
+ }
512
+ const parentControl = form.controls.find((c) => c.key === parentField);
513
+ if (!parentControl) {
514
+ throw new Error(`Parent field not found: ${parentField}`);
515
+ }
516
+ const controlType = this.getControlType(parentControl.type);
517
+ if (!controlType?.getSubControls) {
518
+ throw new Error(
519
+ `Control type '${parentControl.type}' is not a composite type`
520
+ );
521
+ }
522
+ const subControls = controlType.getSubControls(parentControl, this.runtime);
523
+ const subControl = subControls.find((c) => c.key === subField);
524
+ if (!subControl) {
525
+ throw new Error(`Subfield not found: ${subField} in ${parentField}`);
526
+ }
527
+ const now = Date.now();
528
+ if (!session.fields[parentField]) {
529
+ session.fields[parentField] = { status: "empty" };
530
+ }
531
+ if (!session.fields[parentField].subFields) {
532
+ session.fields[parentField].subFields = {};
533
+ }
534
+ let subFieldStatus;
535
+ let error;
536
+ if (controlType.validate) {
537
+ const result = controlType.validate(value, subControl);
538
+ if (!result.valid) {
539
+ subFieldStatus = "invalid";
540
+ error = result.error;
541
+ } else if (confidence < (subControl.confirmThreshold ?? 0.8)) {
542
+ subFieldStatus = "uncertain";
543
+ } else {
544
+ subFieldStatus = "filled";
545
+ }
546
+ } else {
547
+ const validation = validateField(value, subControl);
548
+ if (!validation.valid) {
549
+ subFieldStatus = "invalid";
550
+ error = validation.error;
551
+ } else if (confidence < (subControl.confirmThreshold ?? 0.8)) {
552
+ subFieldStatus = "uncertain";
553
+ } else {
554
+ subFieldStatus = "filled";
555
+ }
556
+ }
557
+ session.fields[parentField].subFields[subField] = {
558
+ status: subFieldStatus,
559
+ value,
560
+ confidence,
561
+ source: "extraction",
562
+ messageId,
563
+ updatedAt: now,
564
+ error
565
+ };
566
+ session.effort.interactionCount++;
567
+ session.effort.lastInteractionAt = now;
568
+ session.effort.timeSpentMs = now - session.effort.firstInteractionAt;
569
+ session.updatedAt = now;
570
+ await storageSaveSession(this.runtime, session);
571
+ logger.debug(`[FormService] Updated subfield ${parentField}.${subField}`);
572
+ }
573
+ /**
574
+ * Check if all subfields of a composite field are filled.
575
+ *
576
+ * @param session - The form session
577
+ * @param parentField - The parent control key
578
+ * @returns true if all required subfields are filled
579
+ */
580
+ areSubFieldsFilled(session, parentField) {
581
+ const form = this.getForm(session.formId);
582
+ if (!form) return false;
583
+ const parentControl = form.controls.find((c) => c.key === parentField);
584
+ if (!parentControl) return false;
585
+ const controlType = this.getControlType(parentControl.type);
586
+ if (!controlType?.getSubControls) return false;
587
+ const subControls = controlType.getSubControls(parentControl, this.runtime);
588
+ const subFields = session.fields[parentField]?.subFields || {};
589
+ for (const subControl of subControls) {
590
+ if (!subControl.required) continue;
591
+ const subField = subFields[subControl.key];
592
+ if (!subField || subField.status !== "filled") {
593
+ return false;
594
+ }
595
+ }
596
+ return true;
597
+ }
598
+ /**
599
+ * Get the current subfield values for a composite field.
600
+ *
601
+ * @param session - The form session
602
+ * @param parentField - The parent control key
603
+ * @returns Record of subfield key to value
604
+ */
605
+ getSubFieldValues(session, parentField) {
606
+ const subFields = session.fields[parentField]?.subFields || {};
607
+ const values = {};
608
+ for (const [key, state] of Object.entries(subFields)) {
609
+ if (state.value !== void 0) {
610
+ values[key] = state.value;
611
+ }
612
+ }
613
+ return values;
614
+ }
615
+ // ============================================================================
616
+ // EXTERNAL FIELD ACTIVATION
617
+ // ============================================================================
618
+ /**
619
+ * Activate an external field.
620
+ *
621
+ * External types (payment, signature) require an async activation process.
622
+ * This is called when all subcontrols are filled and the external process
623
+ * should begin (e.g., generate payment address, show signing instructions).
624
+ *
625
+ * WHY this method:
626
+ * - Decouples activation trigger from the widget itself
627
+ * - Stores activation state in the session
628
+ * - Provides a clear API for the evaluator to call
629
+ *
630
+ * @param sessionId - The session ID
631
+ * @param entityId - The entity/user ID
632
+ * @param field - The field key
633
+ * @returns The activation details (instructions, reference, etc.)
634
+ */
635
+ async activateExternalField(sessionId, entityId, field) {
636
+ const session = await getSessionById(this.runtime, entityId, sessionId);
637
+ if (!session) {
638
+ throw new Error(`Session not found: ${sessionId}`);
639
+ }
640
+ const form = this.getForm(session.formId);
641
+ if (!form) {
642
+ throw new Error(`Form not found: ${session.formId}`);
643
+ }
644
+ const control = form.controls.find((c) => c.key === field);
645
+ if (!control) {
646
+ throw new Error(`Field not found: ${field}`);
647
+ }
648
+ const controlType = this.getControlType(control.type);
649
+ if (!controlType?.activate) {
650
+ throw new Error(
651
+ `Control type '${control.type}' does not support activation`
652
+ );
653
+ }
654
+ const subValues = this.getSubFieldValues(session, field);
655
+ const context = {
656
+ runtime: this.runtime,
657
+ session,
658
+ control,
659
+ subValues
660
+ };
661
+ const activation = await controlType.activate(context);
662
+ const now = Date.now();
663
+ if (!session.fields[field]) {
664
+ session.fields[field] = { status: "empty" };
665
+ }
666
+ session.fields[field].status = "pending";
667
+ session.fields[field].externalState = {
668
+ status: "pending",
669
+ reference: activation.reference,
670
+ instructions: activation.instructions,
671
+ address: activation.address,
672
+ activatedAt: now
673
+ };
674
+ session.updatedAt = now;
675
+ await storageSaveSession(this.runtime, session);
676
+ logger.info(
677
+ `[FormService] Activated external field ${field} with reference ${activation.reference}`
678
+ );
679
+ return activation;
680
+ }
681
+ /**
682
+ * Confirm an external field.
683
+ *
684
+ * Called by external services (payment, blockchain, etc.) when the
685
+ * external process is complete (e.g., payment received, signature verified).
686
+ *
687
+ * WHY separate from confirmField:
688
+ * - External confirmation includes external data (txId, etc.)
689
+ * - Updates externalState, not just field status
690
+ * - Emits events for other systems to react
691
+ *
692
+ * @param sessionId - The session ID
693
+ * @param entityId - The entity/user ID
694
+ * @param field - The field key
695
+ * @param value - The final value to store (usually the confirmed data)
696
+ * @param externalData - Additional data from the external source (txId, etc.)
697
+ */
698
+ async confirmExternalField(sessionId, entityId, field, value, externalData) {
699
+ const session = await getSessionById(this.runtime, entityId, sessionId);
700
+ if (!session) {
701
+ throw new Error(`Session not found: ${sessionId}`);
702
+ }
703
+ const fieldState = session.fields[field];
704
+ if (!fieldState || fieldState.status !== "pending") {
705
+ logger.warn(
706
+ `[FormService] Cannot confirm field ${field}: not in pending state`
707
+ );
708
+ return;
709
+ }
710
+ const now = Date.now();
711
+ fieldState.status = "filled";
712
+ fieldState.value = value;
713
+ fieldState.source = "external";
714
+ fieldState.updatedAt = now;
715
+ if (fieldState.externalState) {
716
+ fieldState.externalState.status = "confirmed";
717
+ fieldState.externalState.confirmedAt = now;
718
+ fieldState.externalState.externalData = externalData;
719
+ }
720
+ const form = this.getForm(session.formId);
721
+ if (form && this.checkAllRequiredFilled(session, form)) {
722
+ if (session.status === "active") {
723
+ session.status = "ready";
724
+ if (form.hooks?.onReady) {
725
+ await this.executeHook(session, "onReady");
726
+ }
727
+ }
728
+ }
729
+ session.updatedAt = now;
730
+ await storageSaveSession(this.runtime, session);
731
+ try {
732
+ await this.runtime.emitEvent("FORM_FIELD_CONFIRMED", {
733
+ runtime: this.runtime,
734
+ sessionId,
735
+ entityId,
736
+ field,
737
+ value,
738
+ externalData
739
+ });
740
+ } catch (_error) {
741
+ logger.debug(`[FormService] No event handler for FORM_FIELD_CONFIRMED`);
742
+ }
743
+ logger.info(`[FormService] Confirmed external field ${field}`);
744
+ }
745
+ /**
746
+ * Cancel an external field activation.
747
+ *
748
+ * Called when the external process fails, times out, or user cancels.
749
+ *
750
+ * @param sessionId - The session ID
751
+ * @param entityId - The entity/user ID
752
+ * @param field - The field key
753
+ * @param reason - Reason for cancellation
754
+ */
755
+ async cancelExternalField(sessionId, entityId, field, reason) {
756
+ const session = await getSessionById(this.runtime, entityId, sessionId);
757
+ if (!session) {
758
+ throw new Error(`Session not found: ${sessionId}`);
759
+ }
760
+ const form = this.getForm(session.formId);
761
+ const control = form?.controls.find((c) => c.key === field);
762
+ const controlType = control ? this.getControlType(control.type) : void 0;
763
+ if (controlType?.deactivate && control) {
764
+ try {
765
+ await controlType.deactivate({
766
+ runtime: this.runtime,
767
+ session,
768
+ control,
769
+ subValues: this.getSubFieldValues(session, field)
770
+ });
771
+ } catch (error) {
772
+ logger.error(
773
+ `[FormService] Deactivate failed for ${field}: ${String(error)}`
774
+ );
775
+ }
776
+ }
777
+ const fieldState = session.fields[field];
778
+ if (fieldState) {
779
+ fieldState.status = "empty";
780
+ fieldState.error = reason;
781
+ if (fieldState.externalState) {
782
+ fieldState.externalState.status = "failed";
783
+ }
784
+ }
785
+ session.updatedAt = Date.now();
786
+ await storageSaveSession(this.runtime, session);
787
+ try {
788
+ await this.runtime.emitEvent("FORM_FIELD_CANCELLED", {
789
+ runtime: this.runtime,
790
+ sessionId,
791
+ entityId,
792
+ field,
793
+ reason
794
+ });
795
+ } catch (_error) {
796
+ logger.debug(`[FormService] No event handler for FORM_FIELD_CANCELLED`);
797
+ }
798
+ logger.info(`[FormService] Cancelled external field ${field}: ${reason}`);
799
+ }
800
+ // ============================================================================
801
+ // LIFECYCLE
802
+ // ============================================================================
803
+ /**
804
+ * Submit a form session
805
+ */
806
+ async submit(sessionId, entityId) {
807
+ const session = await getSessionById(this.runtime, entityId, sessionId);
808
+ if (!session) {
809
+ throw new Error(`Session not found: ${sessionId}`);
810
+ }
811
+ const form = this.getForm(session.formId);
812
+ if (!form) {
813
+ throw new Error(`Form not found: ${session.formId}`);
814
+ }
815
+ if (!this.checkAllRequiredFilled(session, form)) {
816
+ throw new Error("Not all required fields are filled");
817
+ }
818
+ const now = Date.now();
819
+ const values = createValueMap();
820
+ const mappedValues = createValueMap();
821
+ const files = createValueMap();
822
+ for (const control of form.controls) {
823
+ const fieldState = session.fields[control.key];
824
+ if (fieldState?.value !== void 0) {
825
+ const validation = validateField(fieldState.value, control);
826
+ if (!validation.valid) {
827
+ const error = validation.error ?? "validation failed";
828
+ throw new Error(
829
+ `Field ${control.key} is invalid: ${error}`
830
+ );
831
+ }
832
+ values[control.key] = fieldState.value;
833
+ const dbKey = control.dbbind || control.key;
834
+ mappedValues[dbKey] = fieldState.value;
835
+ }
836
+ if (fieldState?.files) {
837
+ files[control.key] = fieldState.files;
838
+ }
839
+ }
840
+ const submission = {
841
+ id: uuidv4(),
842
+ formId: session.formId,
843
+ formVersion: session.formVersion,
844
+ sessionId: session.id,
845
+ entityId: session.entityId,
846
+ values,
847
+ mappedValues,
848
+ files: Object.keys(files).length > 0 ? files : void 0,
849
+ submittedAt: now,
850
+ meta: session.meta
851
+ };
852
+ await saveSubmission(this.runtime, submission);
853
+ await saveAutofillData(this.runtime, entityId, session.formId, values);
854
+ session.status = "submitted";
855
+ session.submittedAt = now;
856
+ session.updatedAt = now;
857
+ await storageSaveSession(this.runtime, session);
858
+ if (form.hooks?.onSubmit) {
859
+ const submissionPayload = JSON.parse(
860
+ JSON.stringify(submission)
861
+ );
862
+ await this.executeHook(session, "onSubmit", {
863
+ submission: submissionPayload
864
+ });
865
+ }
866
+ logger.debug(`[FormService] Submitted session ${sessionId}`);
867
+ return submission;
868
+ }
869
+ /**
870
+ * Stash a session for later
871
+ */
872
+ async stash(sessionId, entityId) {
873
+ const session = await getSessionById(this.runtime, entityId, sessionId);
874
+ if (!session) {
875
+ throw new Error(`Session not found: ${sessionId}`);
876
+ }
877
+ const form = this.getForm(session.formId);
878
+ session.status = "stashed";
879
+ session.updatedAt = Date.now();
880
+ await storageSaveSession(this.runtime, session);
881
+ if (form?.hooks?.onCancel) {
882
+ }
883
+ logger.debug(`[FormService] Stashed session ${sessionId}`);
884
+ }
885
+ /**
886
+ * Restore a stashed session
887
+ */
888
+ async restore(sessionId, entityId) {
889
+ const session = await getSessionById(this.runtime, entityId, sessionId);
890
+ if (!session) {
891
+ throw new Error(`Session not found: ${sessionId}`);
892
+ }
893
+ if (session.status !== "stashed") {
894
+ throw new Error(`Session is not stashed: ${session.status}`);
895
+ }
896
+ const existing = await storageGetActiveSession(
897
+ this.runtime,
898
+ entityId,
899
+ session.roomId
900
+ );
901
+ if (existing && existing.id !== sessionId) {
902
+ throw new Error(`Active session already exists in room: ${existing.id}`);
903
+ }
904
+ session.status = "active";
905
+ session.updatedAt = Date.now();
906
+ session.expiresAt = this.calculateTTL(session);
907
+ await storageSaveSession(this.runtime, session);
908
+ logger.debug(`[FormService] Restored session ${sessionId}`);
909
+ return session;
910
+ }
911
+ /**
912
+ * Cancel a session
913
+ */
914
+ async cancel(sessionId, entityId, force = false) {
915
+ const session = await getSessionById(this.runtime, entityId, sessionId);
916
+ if (!session) {
917
+ throw new Error(`Session not found: ${sessionId}`);
918
+ }
919
+ if (!force && this.shouldConfirmCancel(session) && !session.cancelConfirmationAsked) {
920
+ session.cancelConfirmationAsked = true;
921
+ session.updatedAt = Date.now();
922
+ await storageSaveSession(this.runtime, session);
923
+ return false;
924
+ }
925
+ const form = this.getForm(session.formId);
926
+ session.status = "cancelled";
927
+ session.updatedAt = Date.now();
928
+ await storageSaveSession(this.runtime, session);
929
+ if (form?.hooks?.onCancel) {
930
+ await this.executeHook(session, "onCancel");
931
+ }
932
+ logger.debug(`[FormService] Cancelled session ${sessionId}`);
933
+ return true;
934
+ }
935
+ // ============================================================================
936
+ // SUBMISSIONS
937
+ // ============================================================================
938
+ /**
939
+ * Get submissions for entity, optionally filtered by form ID
940
+ */
941
+ async getSubmissions(entityId, formId) {
942
+ return storageGetSubmissions(this.runtime, entityId, formId);
943
+ }
944
+ // ============================================================================
945
+ // AUTOFILL
946
+ // ============================================================================
947
+ /**
948
+ * Get autofill data for a form
949
+ */
950
+ async getAutofill(entityId, formId) {
951
+ const data = await getAutofillData(this.runtime, entityId, formId);
952
+ return data?.values || null;
953
+ }
954
+ /**
955
+ * Apply autofill to a session
956
+ */
957
+ async applyAutofill(session) {
958
+ const form = this.getForm(session.formId);
959
+ if (!form?.ux?.allowAutofill) {
960
+ return [];
961
+ }
962
+ const autofill = await getAutofillData(
963
+ this.runtime,
964
+ session.entityId,
965
+ session.formId
966
+ );
967
+ if (!autofill) {
968
+ return [];
969
+ }
970
+ const appliedFields = [];
971
+ const now = Date.now();
972
+ for (const control of form.controls) {
973
+ if (session.fields[control.key]?.status !== "empty") {
974
+ continue;
975
+ }
976
+ const value = autofill.values[control.key];
977
+ if (value !== void 0) {
978
+ session.fields[control.key] = {
979
+ status: "filled",
980
+ value,
981
+ source: "autofill",
982
+ updatedAt: now
983
+ };
984
+ appliedFields.push(control.key);
985
+ }
986
+ }
987
+ if (appliedFields.length > 0) {
988
+ session.updatedAt = now;
989
+ await storageSaveSession(this.runtime, session);
990
+ }
991
+ return appliedFields;
992
+ }
993
+ // ============================================================================
994
+ // CONTEXT HELPERS
995
+ // ============================================================================
996
+ /**
997
+ * Get session context for provider
998
+ */
999
+ getSessionContext(session) {
1000
+ const form = this.getForm(session.formId);
1001
+ if (!form) {
1002
+ return {
1003
+ hasActiveForm: false,
1004
+ progress: 0,
1005
+ filledFields: [],
1006
+ missingRequired: [],
1007
+ uncertainFields: [],
1008
+ nextField: null,
1009
+ pendingExternalFields: []
1010
+ };
1011
+ }
1012
+ const filledFields = [];
1013
+ const missingRequired = [];
1014
+ const uncertainFields = [];
1015
+ const pendingExternalFields = [];
1016
+ let nextField = null;
1017
+ let filledCount = 0;
1018
+ let totalRequired = 0;
1019
+ for (const control of form.controls) {
1020
+ if (control.hidden) continue;
1021
+ const fieldState = session.fields[control.key];
1022
+ if (control.required) {
1023
+ totalRequired++;
1024
+ }
1025
+ if (fieldState?.status === "filled") {
1026
+ filledCount++;
1027
+ filledFields.push({
1028
+ key: control.key,
1029
+ label: control.label,
1030
+ displayValue: formatValue(fieldState.value ?? null, control)
1031
+ });
1032
+ } else if (fieldState?.status === "pending") {
1033
+ if (fieldState.externalState) {
1034
+ pendingExternalFields.push({
1035
+ key: control.key,
1036
+ label: control.label,
1037
+ instructions: fieldState.externalState.instructions || "Waiting for confirmation...",
1038
+ reference: fieldState.externalState.reference || "",
1039
+ activatedAt: fieldState.externalState.activatedAt || Date.now(),
1040
+ address: fieldState.externalState.address
1041
+ });
1042
+ }
1043
+ } else if (fieldState?.status === "uncertain") {
1044
+ uncertainFields.push({
1045
+ key: control.key,
1046
+ label: control.label,
1047
+ value: fieldState.value ?? null,
1048
+ confidence: fieldState.confidence ?? 0
1049
+ });
1050
+ } else if (fieldState?.status === "invalid") {
1051
+ missingRequired.push({
1052
+ key: control.key,
1053
+ label: control.label,
1054
+ description: control.description,
1055
+ askPrompt: control.askPrompt
1056
+ });
1057
+ if (!nextField) nextField = control;
1058
+ } else if (control.required && fieldState?.status !== "skipped") {
1059
+ missingRequired.push({
1060
+ key: control.key,
1061
+ label: control.label,
1062
+ description: control.description,
1063
+ askPrompt: control.askPrompt
1064
+ });
1065
+ if (!nextField) nextField = control;
1066
+ } else if (!nextField && fieldState?.status === "empty") {
1067
+ nextField = control;
1068
+ }
1069
+ }
1070
+ const progress = totalRequired > 0 ? Math.round(filledCount / totalRequired * 100) : 100;
1071
+ return {
1072
+ hasActiveForm: true,
1073
+ formId: session.formId,
1074
+ formName: form.name,
1075
+ progress,
1076
+ filledFields,
1077
+ missingRequired,
1078
+ uncertainFields,
1079
+ nextField,
1080
+ status: session.status,
1081
+ pendingCancelConfirmation: session.cancelConfirmationAsked && session.status === "active",
1082
+ pendingExternalFields
1083
+ };
1084
+ }
1085
+ /**
1086
+ * Get current values from session
1087
+ */
1088
+ getValues(session) {
1089
+ const values = createValueMap();
1090
+ for (const [key, state] of Object.entries(session.fields)) {
1091
+ if (state.value !== void 0) {
1092
+ values[key] = state.value;
1093
+ }
1094
+ }
1095
+ return values;
1096
+ }
1097
+ /**
1098
+ * Get mapped values (using dbbind)
1099
+ */
1100
+ getMappedValues(session) {
1101
+ const form = this.getForm(session.formId);
1102
+ if (!form) return {};
1103
+ const values = createValueMap();
1104
+ for (const control of form.controls) {
1105
+ const state = session.fields[control.key];
1106
+ if (state?.value !== void 0) {
1107
+ const key = control.dbbind || control.key;
1108
+ values[key] = state.value;
1109
+ }
1110
+ }
1111
+ return values;
1112
+ }
1113
+ // ============================================================================
1114
+ // TTL & EFFORT
1115
+ // ============================================================================
1116
+ /**
1117
+ * Calculate TTL based on effort
1118
+ */
1119
+ calculateTTL(session) {
1120
+ const form = this.getForm(session.formId);
1121
+ const config = form?.ttl || {};
1122
+ const minDays = config.minDays ?? 14;
1123
+ const maxDays = config.maxDays ?? 90;
1124
+ const multiplier = config.effortMultiplier ?? 0.5;
1125
+ const minutesSpent = session.effort.timeSpentMs / 6e4;
1126
+ const effortDays = minutesSpent * multiplier;
1127
+ const ttlDays = Math.min(maxDays, Math.max(minDays, effortDays));
1128
+ return Date.now() + ttlDays * 24 * 60 * 60 * 1e3;
1129
+ }
1130
+ /**
1131
+ * Check if cancel should require confirmation
1132
+ */
1133
+ shouldConfirmCancel(session) {
1134
+ const minEffortMs = 5 * 60 * 1e3;
1135
+ return session.effort.timeSpentMs > minEffortMs;
1136
+ }
1137
+ // ============================================================================
1138
+ // HOOKS
1139
+ // ============================================================================
1140
+ /**
1141
+ * Execute a form hook
1142
+ */
1143
+ async executeHook(session, hookName, options) {
1144
+ const form = this.getForm(session.formId);
1145
+ const workerName = form?.hooks?.[hookName];
1146
+ if (!workerName) return;
1147
+ const worker = this.runtime.getTaskWorker(workerName);
1148
+ if (!worker) {
1149
+ logger.warn(`[FormService] Hook worker not found: ${workerName}`);
1150
+ return;
1151
+ }
1152
+ try {
1153
+ const task = {
1154
+ id: session.id,
1155
+ name: workerName,
1156
+ roomId: session.roomId,
1157
+ entityId: session.entityId,
1158
+ tags: []
1159
+ };
1160
+ await worker.execute(
1161
+ this.runtime,
1162
+ {
1163
+ session,
1164
+ form,
1165
+ ...options
1166
+ },
1167
+ task
1168
+ );
1169
+ } catch (error) {
1170
+ logger.error(
1171
+ `[FormService] Hook execution failed: ${hookName}`,
1172
+ String(error)
1173
+ );
1174
+ }
1175
+ }
1176
+ // ============================================================================
1177
+ // HELPERS
1178
+ // ============================================================================
1179
+ /**
1180
+ * Check if all required fields are filled
1181
+ */
1182
+ checkAllRequiredFilled(session, form) {
1183
+ for (const control of form.controls) {
1184
+ if (!control.required) continue;
1185
+ const fieldState = session.fields[control.key];
1186
+ if (!fieldState || fieldState.status === "empty" || fieldState.status === "invalid") {
1187
+ return false;
1188
+ }
1189
+ }
1190
+ return true;
1191
+ }
1192
+ }
1193
+ function prettify(key) {
1194
+ return key.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
1195
+ }
1196
+ export {
1197
+ FormService
1198
+ };
1199
+ //# sourceMappingURL=service.js.map