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