@elizaos/plugin-form 2.0.0-alpha.1 → 2.0.0-alpha.11

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 (48) hide show
  1. package/dist/chunk-4B5QLNVA.js +187 -0
  2. package/dist/chunk-ARWZY3NX.js +284 -0
  3. package/dist/chunk-R4VBS2YK.js +1597 -0
  4. package/dist/chunk-TBCL2ILB.js +172 -0
  5. package/dist/chunk-WY4WK3HD.js +57 -0
  6. package/dist/chunk-XHECCAUT.js +544 -0
  7. package/dist/chunk-YTWANJ3R.js +64 -0
  8. package/dist/context-MHPFYZZ2.js +9 -0
  9. package/dist/extractor-UWASKXKD.js +11 -0
  10. package/dist/index.d.ts +3057 -19
  11. package/dist/index.js +428 -2826
  12. package/dist/restore-S7JLME4H.js +9 -0
  13. package/dist/service-TCCXKV3T.js +7 -0
  14. package/package.json +33 -23
  15. package/LICENSE +0 -21
  16. package/README.md +0 -846
  17. package/dist/actions/restore.d.ts +0 -62
  18. package/dist/actions/restore.d.ts.map +0 -1
  19. package/dist/builder.d.ts +0 -320
  20. package/dist/builder.d.ts.map +0 -1
  21. package/dist/builtins.d.ts +0 -128
  22. package/dist/builtins.d.ts.map +0 -1
  23. package/dist/defaults.d.ts +0 -95
  24. package/dist/defaults.d.ts.map +0 -1
  25. package/dist/evaluators/extractor.d.ts +0 -91
  26. package/dist/evaluators/extractor.d.ts.map +0 -1
  27. package/dist/extraction.d.ts +0 -105
  28. package/dist/extraction.d.ts.map +0 -1
  29. package/dist/index.d.ts.map +0 -1
  30. package/dist/index.js.map +0 -30
  31. package/dist/intent.d.ts +0 -116
  32. package/dist/intent.d.ts.map +0 -1
  33. package/dist/providers/context.d.ts +0 -69
  34. package/dist/providers/context.d.ts.map +0 -1
  35. package/dist/service.d.ts +0 -417
  36. package/dist/service.d.ts.map +0 -1
  37. package/dist/storage.d.ts +0 -228
  38. package/dist/storage.d.ts.map +0 -1
  39. package/dist/tasks/nudge.d.ts +0 -89
  40. package/dist/tasks/nudge.d.ts.map +0 -1
  41. package/dist/template.d.ts +0 -10
  42. package/dist/template.d.ts.map +0 -1
  43. package/dist/ttl.d.ts +0 -144
  44. package/dist/ttl.d.ts.map +0 -1
  45. package/dist/types.d.ts +0 -1214
  46. package/dist/types.d.ts.map +0 -1
  47. package/dist/validation.d.ts +0 -156
  48. package/dist/validation.d.ts.map +0 -1
@@ -0,0 +1,1597 @@
1
+ import {
2
+ formatValue,
3
+ getTypeHandler,
4
+ registerTypeHandler,
5
+ validateField
6
+ } from "./chunk-ARWZY3NX.js";
7
+
8
+ // src/service.ts
9
+ import {
10
+ logger,
11
+ Service
12
+ } from "@elizaos/core";
13
+ import { v4 as uuidv42 } from "uuid";
14
+
15
+ // src/builtins.ts
16
+ var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
17
+ var ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
18
+ var textType = {
19
+ id: "text",
20
+ builtin: true,
21
+ validate: (value, control) => {
22
+ if (value === null || value === void 0) {
23
+ return { valid: true };
24
+ }
25
+ const str = String(value);
26
+ if (control.minLength !== void 0 && str.length < control.minLength) {
27
+ return {
28
+ valid: false,
29
+ error: `Must be at least ${control.minLength} characters`
30
+ };
31
+ }
32
+ if (control.maxLength !== void 0 && str.length > control.maxLength) {
33
+ return {
34
+ valid: false,
35
+ error: `Must be at most ${control.maxLength} characters`
36
+ };
37
+ }
38
+ if (control.pattern) {
39
+ const regex = new RegExp(control.pattern);
40
+ if (!regex.test(str)) {
41
+ return { valid: false, error: "Invalid format" };
42
+ }
43
+ }
44
+ if (control.enum && !control.enum.includes(str)) {
45
+ return {
46
+ valid: false,
47
+ error: `Must be one of: ${control.enum.join(", ")}`
48
+ };
49
+ }
50
+ return { valid: true };
51
+ },
52
+ parse: (value) => String(value).trim(),
53
+ format: (value) => String(value ?? ""),
54
+ extractionPrompt: "a text string"
55
+ };
56
+ var numberType = {
57
+ id: "number",
58
+ builtin: true,
59
+ validate: (value, control) => {
60
+ if (value === null || value === void 0 || value === "") {
61
+ return { valid: true };
62
+ }
63
+ const num = typeof value === "number" ? value : parseFloat(String(value));
64
+ if (Number.isNaN(num)) {
65
+ return { valid: false, error: "Must be a valid number" };
66
+ }
67
+ if (control.min !== void 0 && num < control.min) {
68
+ return { valid: false, error: `Must be at least ${control.min}` };
69
+ }
70
+ if (control.max !== void 0 && num > control.max) {
71
+ return { valid: false, error: `Must be at most ${control.max}` };
72
+ }
73
+ return { valid: true };
74
+ },
75
+ parse: (value) => {
76
+ const cleaned = value.replace(/[,$\s]/g, "");
77
+ return parseFloat(cleaned);
78
+ },
79
+ format: (value) => {
80
+ if (value === null || value === void 0) return "";
81
+ const num = typeof value === "number" ? value : parseFloat(String(value));
82
+ if (Number.isNaN(num)) return String(value);
83
+ return num.toLocaleString();
84
+ },
85
+ extractionPrompt: "a number (integer or decimal)"
86
+ };
87
+ var emailType = {
88
+ id: "email",
89
+ builtin: true,
90
+ validate: (value) => {
91
+ if (value === null || value === void 0 || value === "") {
92
+ return { valid: true };
93
+ }
94
+ const str = String(value).trim().toLowerCase();
95
+ if (!EMAIL_REGEX.test(str)) {
96
+ return { valid: false, error: "Invalid email format" };
97
+ }
98
+ return { valid: true };
99
+ },
100
+ parse: (value) => value.trim().toLowerCase(),
101
+ format: (value) => String(value ?? "").toLowerCase(),
102
+ extractionPrompt: "an email address (e.g., user@example.com)"
103
+ };
104
+ var booleanType = {
105
+ id: "boolean",
106
+ builtin: true,
107
+ validate: (value) => {
108
+ if (value === null || value === void 0) {
109
+ return { valid: true };
110
+ }
111
+ if (typeof value === "boolean") {
112
+ return { valid: true };
113
+ }
114
+ const str = String(value).toLowerCase();
115
+ const validValues = ["true", "false", "yes", "no", "1", "0", "on", "off"];
116
+ if (!validValues.includes(str)) {
117
+ return { valid: false, error: "Must be yes/no or true/false" };
118
+ }
119
+ return { valid: true };
120
+ },
121
+ parse: (value) => {
122
+ const str = value.toLowerCase();
123
+ return ["true", "yes", "1", "on"].includes(str);
124
+ },
125
+ format: (value) => {
126
+ if (value === true) return "Yes";
127
+ if (value === false) return "No";
128
+ return String(value ?? "");
129
+ },
130
+ extractionPrompt: "a yes/no or true/false value"
131
+ };
132
+ var selectType = {
133
+ id: "select",
134
+ builtin: true,
135
+ validate: (value, control) => {
136
+ if (value === null || value === void 0 || value === "") {
137
+ return { valid: true };
138
+ }
139
+ const str = String(value);
140
+ if (control.options) {
141
+ const validValues = control.options.map((o) => o.value);
142
+ if (!validValues.includes(str)) {
143
+ const labels = control.options.map((o) => o.label).join(", ");
144
+ return { valid: false, error: `Must be one of: ${labels}` };
145
+ }
146
+ }
147
+ if (control.enum && !control.options) {
148
+ if (!control.enum.includes(str)) {
149
+ return {
150
+ valid: false,
151
+ error: `Must be one of: ${control.enum.join(", ")}`
152
+ };
153
+ }
154
+ }
155
+ return { valid: true };
156
+ },
157
+ parse: (value) => value.trim(),
158
+ format: (value) => String(value ?? ""),
159
+ // Note: extractionPrompt is typically customized per-field with option labels
160
+ extractionPrompt: "one of the available options"
161
+ };
162
+ var dateType = {
163
+ id: "date",
164
+ builtin: true,
165
+ validate: (value) => {
166
+ if (value === null || value === void 0 || value === "") {
167
+ return { valid: true };
168
+ }
169
+ const str = String(value);
170
+ if (!ISO_DATE_REGEX.test(str)) {
171
+ return { valid: false, error: "Must be in YYYY-MM-DD format" };
172
+ }
173
+ const date = new Date(str);
174
+ if (Number.isNaN(date.getTime())) {
175
+ return { valid: false, error: "Invalid date" };
176
+ }
177
+ return { valid: true };
178
+ },
179
+ parse: (value) => {
180
+ const date = new Date(value);
181
+ if (!Number.isNaN(date.getTime())) {
182
+ return date.toISOString().split("T")[0];
183
+ }
184
+ return value.trim();
185
+ },
186
+ format: (value) => {
187
+ if (!value) return "";
188
+ const date = new Date(String(value));
189
+ if (Number.isNaN(date.getTime())) return String(value);
190
+ return date.toLocaleDateString();
191
+ },
192
+ extractionPrompt: "a date (preferably in YYYY-MM-DD format)"
193
+ };
194
+ var fileType = {
195
+ id: "file",
196
+ builtin: true,
197
+ validate: (value, _control) => {
198
+ if (value === null || value === void 0) {
199
+ return { valid: true };
200
+ }
201
+ if (typeof value === "object") {
202
+ return { valid: true };
203
+ }
204
+ return { valid: false, error: "Invalid file data" };
205
+ },
206
+ format: (value) => {
207
+ if (!value) return "";
208
+ if (Array.isArray(value)) {
209
+ return `${value.length} file(s)`;
210
+ }
211
+ if (typeof value === "object" && value !== null && "name" in value) {
212
+ return String(value.name);
213
+ }
214
+ return "File attached";
215
+ },
216
+ extractionPrompt: "a file attachment (upload required)"
217
+ };
218
+ var BUILTIN_TYPES = [
219
+ textType,
220
+ numberType,
221
+ emailType,
222
+ booleanType,
223
+ selectType,
224
+ dateType,
225
+ fileType
226
+ ];
227
+ var BUILTIN_TYPE_MAP = new Map(
228
+ BUILTIN_TYPES.map((t) => [t.id, t])
229
+ );
230
+ function registerBuiltinTypes(registerFn) {
231
+ for (const type of BUILTIN_TYPES) {
232
+ registerFn(type);
233
+ }
234
+ }
235
+ function getBuiltinType(id) {
236
+ return BUILTIN_TYPE_MAP.get(id);
237
+ }
238
+ function isBuiltinType(id) {
239
+ return BUILTIN_TYPE_MAP.has(id);
240
+ }
241
+
242
+ // src/storage.ts
243
+ import { v4 as uuidv4 } from "uuid";
244
+
245
+ // src/types.ts
246
+ var FORM_CONTROL_DEFAULTS = {
247
+ type: "text",
248
+ required: false,
249
+ confirmThreshold: 0.8
250
+ };
251
+ var FORM_DEFINITION_DEFAULTS = {
252
+ version: 1,
253
+ status: "active",
254
+ ux: {
255
+ allowUndo: true,
256
+ allowSkip: true,
257
+ maxUndoSteps: 5,
258
+ showExamples: true,
259
+ showExplanations: true,
260
+ allowAutofill: true
261
+ },
262
+ ttl: {
263
+ minDays: 14,
264
+ maxDays: 90,
265
+ effortMultiplier: 0.5
266
+ },
267
+ nudge: {
268
+ enabled: true,
269
+ afterInactiveHours: 48,
270
+ maxNudges: 3
271
+ },
272
+ debug: false
273
+ };
274
+ var FORM_SESSION_COMPONENT = "form_session";
275
+ var FORM_SUBMISSION_COMPONENT = "form_submission";
276
+ var FORM_AUTOFILL_COMPONENT = "form_autofill";
277
+
278
+ // src/storage.ts
279
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
280
+ var resolveComponentContext = async (runtime, roomId) => {
281
+ if (roomId) {
282
+ const room = await runtime.getRoom(roomId);
283
+ return { roomId, worldId: room?.worldId ?? runtime.agentId };
284
+ }
285
+ return { roomId: runtime.agentId, worldId: runtime.agentId };
286
+ };
287
+ var isFormSession = (data) => {
288
+ if (!isRecord(data)) return false;
289
+ return typeof data.id === "string" && typeof data.formId === "string" && typeof data.entityId === "string" && typeof data.roomId === "string";
290
+ };
291
+ var isFormSubmission = (data) => {
292
+ if (!isRecord(data)) return false;
293
+ return typeof data.id === "string" && typeof data.formId === "string" && typeof data.sessionId === "string" && typeof data.entityId === "string";
294
+ };
295
+ var isFormAutofillData = (data) => {
296
+ if (!isRecord(data)) return false;
297
+ return typeof data.formId === "string" && typeof data.updatedAt === "number" && typeof data.values === "object";
298
+ };
299
+ async function getActiveSession(runtime, entityId, roomId) {
300
+ const component = await runtime.getComponent(entityId, `${FORM_SESSION_COMPONENT}:${roomId}`);
301
+ if (!component?.data || !isFormSession(component.data)) return null;
302
+ const session = component.data;
303
+ if (session.status === "active" || session.status === "ready") {
304
+ return session;
305
+ }
306
+ return null;
307
+ }
308
+ async function getAllActiveSessions(runtime, entityId) {
309
+ const components = await runtime.getComponents(entityId);
310
+ const sessions = [];
311
+ for (const component of components) {
312
+ if (component.type.startsWith(`${FORM_SESSION_COMPONENT}:`)) {
313
+ if (component.data && isFormSession(component.data)) {
314
+ const session = component.data;
315
+ if (session.status === "active" || session.status === "ready") {
316
+ sessions.push(session);
317
+ }
318
+ }
319
+ }
320
+ }
321
+ return sessions;
322
+ }
323
+ async function getStashedSessions(runtime, entityId) {
324
+ const components = await runtime.getComponents(entityId);
325
+ const sessions = [];
326
+ for (const component of components) {
327
+ if (component.type.startsWith(`${FORM_SESSION_COMPONENT}:`)) {
328
+ if (component.data && isFormSession(component.data)) {
329
+ const session = component.data;
330
+ if (session.status === "stashed") {
331
+ sessions.push(session);
332
+ }
333
+ }
334
+ }
335
+ }
336
+ return sessions;
337
+ }
338
+ async function getSessionById(runtime, entityId, sessionId) {
339
+ const components = await runtime.getComponents(entityId);
340
+ for (const component of components) {
341
+ if (component.type.startsWith(`${FORM_SESSION_COMPONENT}:`)) {
342
+ if (component.data && isFormSession(component.data)) {
343
+ const session = component.data;
344
+ if (session.id === sessionId) {
345
+ return session;
346
+ }
347
+ }
348
+ }
349
+ }
350
+ return null;
351
+ }
352
+ async function saveSession(runtime, session) {
353
+ const componentType = `${FORM_SESSION_COMPONENT}:${session.roomId}`;
354
+ const existing = await runtime.getComponent(session.entityId, componentType);
355
+ const context = await resolveComponentContext(runtime, session.roomId);
356
+ const resolvedWorldId = existing?.worldId ?? context.worldId;
357
+ const component = {
358
+ id: existing?.id || uuidv4(),
359
+ entityId: session.entityId,
360
+ agentId: runtime.agentId,
361
+ roomId: session.roomId,
362
+ // WHY preserve worldId: Avoids breaking existing component relationships
363
+ worldId: resolvedWorldId,
364
+ sourceEntityId: runtime.agentId,
365
+ type: componentType,
366
+ createdAt: existing?.createdAt || Date.now(),
367
+ // Store session as component data
368
+ data: JSON.parse(JSON.stringify(session))
369
+ };
370
+ if (existing) {
371
+ await runtime.updateComponent(component);
372
+ } else {
373
+ await runtime.createComponent(component);
374
+ }
375
+ }
376
+ async function deleteSession(runtime, session) {
377
+ const componentType = `${FORM_SESSION_COMPONENT}:${session.roomId}`;
378
+ const existing = await runtime.getComponent(session.entityId, componentType);
379
+ if (existing) {
380
+ await runtime.deleteComponent(existing.id);
381
+ }
382
+ }
383
+ async function saveSubmission(runtime, submission) {
384
+ const componentType = `${FORM_SUBMISSION_COMPONENT}:${submission.formId}:${submission.id}`;
385
+ const context = await resolveComponentContext(runtime);
386
+ const component = {
387
+ id: uuidv4(),
388
+ entityId: submission.entityId,
389
+ agentId: runtime.agentId,
390
+ roomId: context.roomId,
391
+ worldId: context.worldId,
392
+ sourceEntityId: runtime.agentId,
393
+ type: componentType,
394
+ createdAt: submission.submittedAt,
395
+ data: JSON.parse(JSON.stringify(submission))
396
+ };
397
+ await runtime.createComponent(component);
398
+ }
399
+ async function getSubmissions(runtime, entityId, formId) {
400
+ const components = await runtime.getComponents(entityId);
401
+ const submissions = [];
402
+ const prefix = formId ? `${FORM_SUBMISSION_COMPONENT}:${formId}:` : `${FORM_SUBMISSION_COMPONENT}:`;
403
+ for (const component of components) {
404
+ if (component.type.startsWith(prefix)) {
405
+ if (component.data && isFormSubmission(component.data)) {
406
+ submissions.push(component.data);
407
+ }
408
+ }
409
+ }
410
+ submissions.sort((a, b) => b.submittedAt - a.submittedAt);
411
+ return submissions;
412
+ }
413
+ async function getAutofillData(runtime, entityId, formId) {
414
+ const componentType = `${FORM_AUTOFILL_COMPONENT}:${formId}`;
415
+ const component = await runtime.getComponent(entityId, componentType);
416
+ if (!component?.data || !isFormAutofillData(component.data)) return null;
417
+ return component.data;
418
+ }
419
+ async function saveAutofillData(runtime, entityId, formId, values) {
420
+ const componentType = `${FORM_AUTOFILL_COMPONENT}:${formId}`;
421
+ const existing = await runtime.getComponent(entityId, componentType);
422
+ const context = await resolveComponentContext(runtime);
423
+ const resolvedWorldId = existing?.worldId ?? context.worldId;
424
+ const data = {
425
+ formId,
426
+ values,
427
+ updatedAt: Date.now()
428
+ };
429
+ const component = {
430
+ id: existing?.id || uuidv4(),
431
+ entityId,
432
+ agentId: runtime.agentId,
433
+ roomId: context.roomId,
434
+ worldId: resolvedWorldId,
435
+ sourceEntityId: runtime.agentId,
436
+ type: componentType,
437
+ createdAt: existing?.createdAt || Date.now(),
438
+ data: JSON.parse(JSON.stringify(data))
439
+ };
440
+ if (existing) {
441
+ await runtime.updateComponent(component);
442
+ } else {
443
+ await runtime.createComponent(component);
444
+ }
445
+ }
446
+
447
+ // src/service.ts
448
+ var FormService = class _FormService extends Service {
449
+ /** Service type identifier for runtime.getService() */
450
+ static serviceType = "FORM";
451
+ /** Description shown in agent capabilities */
452
+ capabilityDescription = "Manages conversational forms for data collection";
453
+ /**
454
+ * In-memory storage of form definitions.
455
+ *
456
+ * WHY Map:
457
+ * - O(1) lookup by ID
458
+ * - Forms are static after registration
459
+ * - No persistence needed (re-registered on startup)
460
+ */
461
+ forms = /* @__PURE__ */ new Map();
462
+ /**
463
+ * Control type registry.
464
+ *
465
+ * WHY separate from TypeHandler:
466
+ * - ControlType is the new unified interface
467
+ * - Supports simple, composite, and external types
468
+ * - TypeHandler is legacy but still supported
469
+ *
470
+ * Built-in types are registered on start.
471
+ * Plugins can register custom types.
472
+ */
473
+ controlTypes = /* @__PURE__ */ new Map();
474
+ /**
475
+ * Start the FormService
476
+ */
477
+ static async start(runtime) {
478
+ const service = new _FormService(runtime);
479
+ registerBuiltinTypes((type, options) => service.registerControlType(type, options));
480
+ logger.info("[FormService] Started with built-in types");
481
+ return service;
482
+ }
483
+ /**
484
+ * Stop the FormService
485
+ */
486
+ async stop() {
487
+ logger.info("[FormService] Stopped");
488
+ }
489
+ // ============================================================================
490
+ // FORM DEFINITION MANAGEMENT
491
+ // ============================================================================
492
+ /**
493
+ * Register a form definition
494
+ */
495
+ registerForm(definition) {
496
+ const form = {
497
+ ...definition,
498
+ version: definition.version ?? FORM_DEFINITION_DEFAULTS.version,
499
+ status: definition.status ?? FORM_DEFINITION_DEFAULTS.status,
500
+ ux: { ...FORM_DEFINITION_DEFAULTS.ux, ...definition.ux },
501
+ ttl: { ...FORM_DEFINITION_DEFAULTS.ttl, ...definition.ttl },
502
+ nudge: { ...FORM_DEFINITION_DEFAULTS.nudge, ...definition.nudge },
503
+ debug: definition.debug ?? FORM_DEFINITION_DEFAULTS.debug,
504
+ controls: definition.controls.map((control) => ({
505
+ ...control,
506
+ type: control.type || FORM_CONTROL_DEFAULTS.type,
507
+ required: control.required ?? FORM_CONTROL_DEFAULTS.required,
508
+ confirmThreshold: control.confirmThreshold ?? FORM_CONTROL_DEFAULTS.confirmThreshold,
509
+ label: control.label || prettify(control.key)
510
+ }))
511
+ };
512
+ this.forms.set(form.id, form);
513
+ logger.debug(`[FormService] Registered form: ${form.id}`);
514
+ }
515
+ /**
516
+ * Get a form definition by ID
517
+ */
518
+ getForm(formId) {
519
+ return this.forms.get(formId);
520
+ }
521
+ /**
522
+ * Get all registered forms
523
+ */
524
+ listForms() {
525
+ return Array.from(this.forms.values());
526
+ }
527
+ // ============================================================================
528
+ // TYPE HANDLER REGISTRY (Legacy)
529
+ // ============================================================================
530
+ /**
531
+ * Register a custom type handler (legacy API)
532
+ * @deprecated Use registerControlType instead
533
+ */
534
+ registerType(type, handler) {
535
+ registerTypeHandler(type, handler);
536
+ logger.debug(`[FormService] Registered type handler: ${type}`);
537
+ }
538
+ /**
539
+ * Get a type handler (legacy API)
540
+ * @deprecated Use getControlType instead
541
+ */
542
+ getTypeHandler(type) {
543
+ return getTypeHandler(type);
544
+ }
545
+ // ============================================================================
546
+ // CONTROL TYPE REGISTRY
547
+ // ============================================================================
548
+ /**
549
+ * Register a control type.
550
+ *
551
+ * Control types define how a field type behaves:
552
+ * - Simple types: validate/parse/format
553
+ * - Composite types: have subcontrols
554
+ * - External types: have activate/deactivate for async processes
555
+ *
556
+ * Built-in types (text, number, email, etc.) are registered at startup
557
+ * and protected from override unless explicitly allowed.
558
+ *
559
+ * @param type - The ControlType definition
560
+ * @param options - Registration options
561
+ * @param options.allowOverride - Allow overriding built-in types (default: false)
562
+ *
563
+ * @example
564
+ * ```typescript
565
+ * formService.registerControlType({
566
+ * id: 'payment',
567
+ * getSubControls: () => [
568
+ * { key: 'amount', type: 'number', label: 'Amount', required: true },
569
+ * { key: 'currency', type: 'select', label: 'Currency', required: true },
570
+ * ],
571
+ * activate: async (ctx) => {
572
+ * const paymentService = ctx.runtime.getService('PAYMENT');
573
+ * return paymentService.createPending(ctx.subValues);
574
+ * },
575
+ * });
576
+ * ```
577
+ */
578
+ registerControlType(type, options) {
579
+ const existing = this.controlTypes.get(type.id);
580
+ if (existing) {
581
+ if (existing.builtin && !options?.allowOverride) {
582
+ logger.warn(
583
+ `[FormService] Cannot override builtin type '${type.id}' without allowOverride: true`
584
+ );
585
+ return;
586
+ }
587
+ logger.warn(`[FormService] Overriding control type: ${type.id}`);
588
+ }
589
+ this.controlTypes.set(type.id, type);
590
+ logger.debug(`[FormService] Registered control type: ${type.id}`);
591
+ }
592
+ /**
593
+ * Get a control type by ID.
594
+ *
595
+ * @param typeId - The type ID to look up
596
+ * @returns The ControlType or undefined if not found
597
+ */
598
+ getControlType(typeId) {
599
+ return this.controlTypes.get(typeId);
600
+ }
601
+ /**
602
+ * List all registered control types.
603
+ *
604
+ * @returns Array of all registered ControlTypes
605
+ */
606
+ listControlTypes() {
607
+ return Array.from(this.controlTypes.values());
608
+ }
609
+ /**
610
+ * Check if a control type has subcontrols.
611
+ *
612
+ * @param typeId - The type ID to check
613
+ * @returns true if the type has getSubControls method
614
+ */
615
+ isCompositeType(typeId) {
616
+ const type = this.controlTypes.get(typeId);
617
+ return !!type?.getSubControls;
618
+ }
619
+ /**
620
+ * Check if a control type is an external type.
621
+ *
622
+ * @param typeId - The type ID to check
623
+ * @returns true if the type has activate method
624
+ */
625
+ isExternalType(typeId) {
626
+ const type = this.controlTypes.get(typeId);
627
+ return !!type?.activate;
628
+ }
629
+ /**
630
+ * Get subcontrols for a composite type.
631
+ *
632
+ * @param control - The parent control
633
+ * @returns Array of subcontrols or empty array if not composite
634
+ */
635
+ getSubControls(control) {
636
+ const type = this.controlTypes.get(control.type);
637
+ if (!type?.getSubControls) {
638
+ return [];
639
+ }
640
+ return type.getSubControls(control, this.runtime);
641
+ }
642
+ // ============================================================================
643
+ // SESSION MANAGEMENT
644
+ // ============================================================================
645
+ /**
646
+ * Start a new form session
647
+ */
648
+ async startSession(formId, entityId, roomId, options) {
649
+ const form = this.getForm(formId);
650
+ if (!form) {
651
+ throw new Error(`Form not found: ${formId}`);
652
+ }
653
+ const existing = await getActiveSession(this.runtime, entityId, roomId);
654
+ if (existing) {
655
+ throw new Error(`Active session already exists for this user/room: ${existing.id}`);
656
+ }
657
+ const now = Date.now();
658
+ const fields = {};
659
+ for (const control of form.controls) {
660
+ if (options?.initialValues?.[control.key] !== void 0) {
661
+ fields[control.key] = {
662
+ status: "filled",
663
+ value: options.initialValues[control.key],
664
+ source: "manual",
665
+ updatedAt: now
666
+ };
667
+ } else if (control.defaultValue !== void 0) {
668
+ fields[control.key] = {
669
+ status: "filled",
670
+ value: control.defaultValue,
671
+ source: "default",
672
+ updatedAt: now
673
+ };
674
+ } else {
675
+ fields[control.key] = { status: "empty" };
676
+ }
677
+ }
678
+ const ttlDays = form.ttl?.minDays ?? 14;
679
+ const expiresAt = now + ttlDays * 24 * 60 * 60 * 1e3;
680
+ const session = {
681
+ id: uuidv42(),
682
+ formId,
683
+ formVersion: form.version,
684
+ entityId,
685
+ roomId,
686
+ status: "active",
687
+ fields,
688
+ history: [],
689
+ context: options?.context,
690
+ locale: options?.locale,
691
+ effort: {
692
+ interactionCount: 0,
693
+ timeSpentMs: 0,
694
+ firstInteractionAt: now,
695
+ lastInteractionAt: now
696
+ },
697
+ expiresAt,
698
+ createdAt: now,
699
+ updatedAt: now
700
+ };
701
+ await saveSession(this.runtime, session);
702
+ if (form.hooks?.onStart) {
703
+ await this.executeHook(session, "onStart");
704
+ }
705
+ logger.debug(`[FormService] Started session ${session.id} for form ${formId}`);
706
+ return session;
707
+ }
708
+ /**
709
+ * Get active session for entity in room
710
+ */
711
+ async getActiveSession(entityId, roomId) {
712
+ return getActiveSession(this.runtime, entityId, roomId);
713
+ }
714
+ /**
715
+ * Get all active sessions for entity (across all rooms)
716
+ */
717
+ async getAllActiveSessions(entityId) {
718
+ return getAllActiveSessions(this.runtime, entityId);
719
+ }
720
+ /**
721
+ * Get stashed sessions for entity
722
+ */
723
+ async getStashedSessions(entityId) {
724
+ return getStashedSessions(this.runtime, entityId);
725
+ }
726
+ /**
727
+ * Save a session
728
+ */
729
+ async saveSession(session) {
730
+ session.updatedAt = Date.now();
731
+ await saveSession(this.runtime, session);
732
+ }
733
+ // ============================================================================
734
+ // FIELD UPDATES
735
+ // ============================================================================
736
+ /**
737
+ * Update a field value
738
+ */
739
+ async updateField(sessionId, entityId, field, value, confidence, source, messageId) {
740
+ const session = await getSessionById(this.runtime, entityId, sessionId);
741
+ if (!session) {
742
+ throw new Error(`Session not found: ${sessionId}`);
743
+ }
744
+ const form = this.getForm(session.formId);
745
+ if (!form) {
746
+ throw new Error(`Form not found: ${session.formId}`);
747
+ }
748
+ const control = form.controls.find((c) => c.key === field);
749
+ if (!control) {
750
+ throw new Error(`Field not found: ${field}`);
751
+ }
752
+ const oldValue = session.fields[field]?.value;
753
+ const validation = validateField(value, control);
754
+ let status;
755
+ if (!validation.valid) {
756
+ status = "invalid";
757
+ } else if (confidence < (control.confirmThreshold ?? 0.8)) {
758
+ status = "uncertain";
759
+ } else {
760
+ status = "filled";
761
+ }
762
+ const now = Date.now();
763
+ if (oldValue !== void 0) {
764
+ const historyEntry = {
765
+ field,
766
+ oldValue,
767
+ newValue: value,
768
+ timestamp: now
769
+ };
770
+ session.history.push(historyEntry);
771
+ const maxUndo = form.ux?.maxUndoSteps ?? 5;
772
+ if (session.history.length > maxUndo) {
773
+ session.history = session.history.slice(-maxUndo);
774
+ }
775
+ }
776
+ session.fields[field] = {
777
+ status,
778
+ value,
779
+ confidence,
780
+ source,
781
+ messageId,
782
+ updatedAt: now,
783
+ error: !validation.valid ? validation.error : void 0
784
+ };
785
+ session.effort.interactionCount++;
786
+ session.effort.lastInteractionAt = now;
787
+ session.effort.timeSpentMs = now - session.effort.firstInteractionAt;
788
+ session.expiresAt = this.calculateTTL(session);
789
+ const allRequiredFilled = this.checkAllRequiredFilled(session, form);
790
+ if (allRequiredFilled && session.status === "active") {
791
+ session.status = "ready";
792
+ if (form.hooks?.onReady) {
793
+ await this.executeHook(session, "onReady");
794
+ }
795
+ }
796
+ session.updatedAt = now;
797
+ await saveSession(this.runtime, session);
798
+ if (form.hooks?.onFieldChange) {
799
+ const hookPayload = { field, value };
800
+ if (oldValue !== void 0) {
801
+ hookPayload.oldValue = oldValue;
802
+ }
803
+ await this.executeHook(session, "onFieldChange", hookPayload);
804
+ }
805
+ }
806
+ /**
807
+ * Undo the last field change
808
+ */
809
+ async undoLastChange(sessionId, entityId) {
810
+ const session = await getSessionById(this.runtime, entityId, sessionId);
811
+ if (!session) {
812
+ throw new Error(`Session not found: ${sessionId}`);
813
+ }
814
+ const form = this.getForm(session.formId);
815
+ if (!form?.ux?.allowUndo) {
816
+ return null;
817
+ }
818
+ const lastChange = session.history.pop();
819
+ if (!lastChange) {
820
+ return null;
821
+ }
822
+ if (lastChange.oldValue !== void 0) {
823
+ session.fields[lastChange.field] = {
824
+ status: "filled",
825
+ value: lastChange.oldValue,
826
+ source: "correction",
827
+ updatedAt: Date.now()
828
+ };
829
+ } else {
830
+ session.fields[lastChange.field] = { status: "empty" };
831
+ }
832
+ session.updatedAt = Date.now();
833
+ await saveSession(this.runtime, session);
834
+ return { field: lastChange.field, restoredValue: lastChange.oldValue };
835
+ }
836
+ /**
837
+ * Skip an optional field
838
+ */
839
+ async skipField(sessionId, entityId, field) {
840
+ const session = await getSessionById(this.runtime, entityId, sessionId);
841
+ if (!session) {
842
+ throw new Error(`Session not found: ${sessionId}`);
843
+ }
844
+ const form = this.getForm(session.formId);
845
+ if (!form?.ux?.allowSkip) {
846
+ return false;
847
+ }
848
+ const control = form.controls.find((c) => c.key === field);
849
+ if (!control) {
850
+ return false;
851
+ }
852
+ if (control.required) {
853
+ return false;
854
+ }
855
+ session.fields[field] = {
856
+ status: "skipped",
857
+ updatedAt: Date.now()
858
+ };
859
+ session.updatedAt = Date.now();
860
+ await saveSession(this.runtime, session);
861
+ return true;
862
+ }
863
+ /**
864
+ * Confirm an uncertain field value
865
+ */
866
+ async confirmField(sessionId, entityId, field, accepted) {
867
+ const session = await getSessionById(this.runtime, entityId, sessionId);
868
+ if (!session) {
869
+ throw new Error(`Session not found: ${sessionId}`);
870
+ }
871
+ const fieldState = session.fields[field];
872
+ if (!fieldState || fieldState.status !== "uncertain") {
873
+ return;
874
+ }
875
+ const now = Date.now();
876
+ if (accepted) {
877
+ fieldState.status = "filled";
878
+ fieldState.confirmedAt = now;
879
+ } else {
880
+ fieldState.status = "empty";
881
+ fieldState.value = void 0;
882
+ fieldState.confidence = void 0;
883
+ }
884
+ fieldState.updatedAt = now;
885
+ session.updatedAt = now;
886
+ await saveSession(this.runtime, session);
887
+ }
888
+ // ============================================================================
889
+ // SUBFIELD UPDATES (for composite types)
890
+ // ============================================================================
891
+ /**
892
+ * Update a subfield value for a composite control type.
893
+ *
894
+ * Composite types (like payment, address) have subcontrols that must
895
+ * all be filled before the parent field is complete.
896
+ *
897
+ * WHY separate from updateField:
898
+ * - Subfields are stored in fieldState.subFields, not session.fields
899
+ * - Parent field status depends on all subfields being filled
900
+ * - Allows tracking subfield confidence/status independently
901
+ *
902
+ * @param sessionId - The session ID
903
+ * @param entityId - The entity/user ID
904
+ * @param parentField - The parent control key (e.g., "payment")
905
+ * @param subField - The subcontrol key (e.g., "amount")
906
+ * @param value - The extracted value
907
+ * @param confidence - LLM confidence (0-1)
908
+ * @param messageId - Optional message ID for audit
909
+ */
910
+ async updateSubField(sessionId, entityId, parentField, subField, value, confidence, messageId) {
911
+ const session = await getSessionById(this.runtime, entityId, sessionId);
912
+ if (!session) {
913
+ throw new Error(`Session not found: ${sessionId}`);
914
+ }
915
+ const form = this.getForm(session.formId);
916
+ if (!form) {
917
+ throw new Error(`Form not found: ${session.formId}`);
918
+ }
919
+ const parentControl = form.controls.find((c) => c.key === parentField);
920
+ if (!parentControl) {
921
+ throw new Error(`Parent field not found: ${parentField}`);
922
+ }
923
+ const controlType = this.getControlType(parentControl.type);
924
+ if (!controlType?.getSubControls) {
925
+ throw new Error(`Control type '${parentControl.type}' is not a composite type`);
926
+ }
927
+ const subControls = controlType.getSubControls(parentControl, this.runtime);
928
+ const subControl = subControls.find((c) => c.key === subField);
929
+ if (!subControl) {
930
+ throw new Error(`Subfield not found: ${subField} in ${parentField}`);
931
+ }
932
+ const now = Date.now();
933
+ if (!session.fields[parentField]) {
934
+ session.fields[parentField] = { status: "empty" };
935
+ }
936
+ if (!session.fields[parentField].subFields) {
937
+ session.fields[parentField].subFields = {};
938
+ }
939
+ let subFieldStatus;
940
+ let error;
941
+ if (controlType.validate) {
942
+ const result = controlType.validate(value, subControl);
943
+ if (!result.valid) {
944
+ subFieldStatus = "invalid";
945
+ error = result.error;
946
+ } else if (confidence < (subControl.confirmThreshold ?? 0.8)) {
947
+ subFieldStatus = "uncertain";
948
+ } else {
949
+ subFieldStatus = "filled";
950
+ }
951
+ } else {
952
+ const validation = validateField(value, subControl);
953
+ if (!validation.valid) {
954
+ subFieldStatus = "invalid";
955
+ error = validation.error;
956
+ } else if (confidence < (subControl.confirmThreshold ?? 0.8)) {
957
+ subFieldStatus = "uncertain";
958
+ } else {
959
+ subFieldStatus = "filled";
960
+ }
961
+ }
962
+ session.fields[parentField].subFields[subField] = {
963
+ status: subFieldStatus,
964
+ value,
965
+ confidence,
966
+ source: "extraction",
967
+ messageId,
968
+ updatedAt: now,
969
+ error
970
+ };
971
+ session.effort.interactionCount++;
972
+ session.effort.lastInteractionAt = now;
973
+ session.effort.timeSpentMs = now - session.effort.firstInteractionAt;
974
+ session.updatedAt = now;
975
+ await saveSession(this.runtime, session);
976
+ logger.debug(`[FormService] Updated subfield ${parentField}.${subField}`);
977
+ }
978
+ /**
979
+ * Check if all subfields of a composite field are filled.
980
+ *
981
+ * @param session - The form session
982
+ * @param parentField - The parent control key
983
+ * @returns true if all required subfields are filled
984
+ */
985
+ areSubFieldsFilled(session, parentField) {
986
+ const form = this.getForm(session.formId);
987
+ if (!form) return false;
988
+ const parentControl = form.controls.find((c) => c.key === parentField);
989
+ if (!parentControl) return false;
990
+ const controlType = this.getControlType(parentControl.type);
991
+ if (!controlType?.getSubControls) return false;
992
+ const subControls = controlType.getSubControls(parentControl, this.runtime);
993
+ const subFields = session.fields[parentField]?.subFields || {};
994
+ for (const subControl of subControls) {
995
+ if (!subControl.required) continue;
996
+ const subField = subFields[subControl.key];
997
+ if (!subField || subField.status !== "filled") {
998
+ return false;
999
+ }
1000
+ }
1001
+ return true;
1002
+ }
1003
+ /**
1004
+ * Get the current subfield values for a composite field.
1005
+ *
1006
+ * @param session - The form session
1007
+ * @param parentField - The parent control key
1008
+ * @returns Record of subfield key to value
1009
+ */
1010
+ getSubFieldValues(session, parentField) {
1011
+ const subFields = session.fields[parentField]?.subFields || {};
1012
+ const values = {};
1013
+ for (const [key, state] of Object.entries(subFields)) {
1014
+ if (state.value !== void 0) {
1015
+ values[key] = state.value;
1016
+ }
1017
+ }
1018
+ return values;
1019
+ }
1020
+ // ============================================================================
1021
+ // EXTERNAL FIELD ACTIVATION
1022
+ // ============================================================================
1023
+ /**
1024
+ * Activate an external field.
1025
+ *
1026
+ * External types (payment, signature) require an async activation process.
1027
+ * This is called when all subcontrols are filled and the external process
1028
+ * should begin (e.g., generate payment address, show signing instructions).
1029
+ *
1030
+ * WHY this method:
1031
+ * - Decouples activation trigger from the widget itself
1032
+ * - Stores activation state in the session
1033
+ * - Provides a clear API for the evaluator to call
1034
+ *
1035
+ * @param sessionId - The session ID
1036
+ * @param entityId - The entity/user ID
1037
+ * @param field - The field key
1038
+ * @returns The activation details (instructions, reference, etc.)
1039
+ */
1040
+ async activateExternalField(sessionId, entityId, field) {
1041
+ const session = await getSessionById(this.runtime, entityId, sessionId);
1042
+ if (!session) {
1043
+ throw new Error(`Session not found: ${sessionId}`);
1044
+ }
1045
+ const form = this.getForm(session.formId);
1046
+ if (!form) {
1047
+ throw new Error(`Form not found: ${session.formId}`);
1048
+ }
1049
+ const control = form.controls.find((c) => c.key === field);
1050
+ if (!control) {
1051
+ throw new Error(`Field not found: ${field}`);
1052
+ }
1053
+ const controlType = this.getControlType(control.type);
1054
+ if (!controlType?.activate) {
1055
+ throw new Error(`Control type '${control.type}' does not support activation`);
1056
+ }
1057
+ const subValues = this.getSubFieldValues(session, field);
1058
+ const context = {
1059
+ runtime: this.runtime,
1060
+ session,
1061
+ control,
1062
+ subValues
1063
+ };
1064
+ const activation = await controlType.activate(context);
1065
+ const now = Date.now();
1066
+ if (!session.fields[field]) {
1067
+ session.fields[field] = { status: "empty" };
1068
+ }
1069
+ session.fields[field].status = "pending";
1070
+ session.fields[field].externalState = {
1071
+ status: "pending",
1072
+ reference: activation.reference,
1073
+ instructions: activation.instructions,
1074
+ address: activation.address,
1075
+ activatedAt: now
1076
+ };
1077
+ session.updatedAt = now;
1078
+ await saveSession(this.runtime, session);
1079
+ logger.info(
1080
+ `[FormService] Activated external field ${field} with reference ${activation.reference}`
1081
+ );
1082
+ return activation;
1083
+ }
1084
+ /**
1085
+ * Confirm an external field.
1086
+ *
1087
+ * Called by external services (payment, blockchain, etc.) when the
1088
+ * external process is complete (e.g., payment received, signature verified).
1089
+ *
1090
+ * WHY separate from confirmField:
1091
+ * - External confirmation includes external data (txId, etc.)
1092
+ * - Updates externalState, not just field status
1093
+ * - Emits events for other systems to react
1094
+ *
1095
+ * @param sessionId - The session ID
1096
+ * @param entityId - The entity/user ID
1097
+ * @param field - The field key
1098
+ * @param value - The final value to store (usually the confirmed data)
1099
+ * @param externalData - Additional data from the external source (txId, etc.)
1100
+ */
1101
+ async confirmExternalField(sessionId, entityId, field, value, externalData) {
1102
+ const session = await getSessionById(this.runtime, entityId, sessionId);
1103
+ if (!session) {
1104
+ throw new Error(`Session not found: ${sessionId}`);
1105
+ }
1106
+ const fieldState = session.fields[field];
1107
+ if (!fieldState || fieldState.status !== "pending") {
1108
+ logger.warn(`[FormService] Cannot confirm field ${field}: not in pending state`);
1109
+ return;
1110
+ }
1111
+ const now = Date.now();
1112
+ fieldState.status = "filled";
1113
+ fieldState.value = value;
1114
+ fieldState.source = "external";
1115
+ fieldState.updatedAt = now;
1116
+ if (fieldState.externalState) {
1117
+ fieldState.externalState.status = "confirmed";
1118
+ fieldState.externalState.confirmedAt = now;
1119
+ fieldState.externalState.externalData = externalData;
1120
+ }
1121
+ const form = this.getForm(session.formId);
1122
+ if (form && this.checkAllRequiredFilled(session, form)) {
1123
+ if (session.status === "active") {
1124
+ session.status = "ready";
1125
+ if (form.hooks?.onReady) {
1126
+ await this.executeHook(session, "onReady");
1127
+ }
1128
+ }
1129
+ }
1130
+ session.updatedAt = now;
1131
+ await saveSession(this.runtime, session);
1132
+ try {
1133
+ await this.runtime.emitEvent("FORM_FIELD_CONFIRMED", {
1134
+ runtime: this.runtime,
1135
+ sessionId,
1136
+ entityId,
1137
+ field,
1138
+ value,
1139
+ externalData
1140
+ });
1141
+ } catch (_error) {
1142
+ logger.debug(`[FormService] No event handler for FORM_FIELD_CONFIRMED`);
1143
+ }
1144
+ logger.info(`[FormService] Confirmed external field ${field}`);
1145
+ }
1146
+ /**
1147
+ * Cancel an external field activation.
1148
+ *
1149
+ * Called when the external process fails, times out, or user cancels.
1150
+ *
1151
+ * @param sessionId - The session ID
1152
+ * @param entityId - The entity/user ID
1153
+ * @param field - The field key
1154
+ * @param reason - Reason for cancellation
1155
+ */
1156
+ async cancelExternalField(sessionId, entityId, field, reason) {
1157
+ const session = await getSessionById(this.runtime, entityId, sessionId);
1158
+ if (!session) {
1159
+ throw new Error(`Session not found: ${sessionId}`);
1160
+ }
1161
+ const form = this.getForm(session.formId);
1162
+ const control = form?.controls.find((c) => c.key === field);
1163
+ const controlType = control ? this.getControlType(control.type) : void 0;
1164
+ if (controlType?.deactivate && control) {
1165
+ try {
1166
+ await controlType.deactivate({
1167
+ runtime: this.runtime,
1168
+ session,
1169
+ control,
1170
+ subValues: this.getSubFieldValues(session, field)
1171
+ });
1172
+ } catch (error) {
1173
+ logger.error(`[FormService] Deactivate failed for ${field}: ${String(error)}`);
1174
+ }
1175
+ }
1176
+ const fieldState = session.fields[field];
1177
+ if (fieldState) {
1178
+ fieldState.status = "empty";
1179
+ fieldState.error = reason;
1180
+ if (fieldState.externalState) {
1181
+ fieldState.externalState.status = "failed";
1182
+ }
1183
+ }
1184
+ session.updatedAt = Date.now();
1185
+ await saveSession(this.runtime, session);
1186
+ try {
1187
+ await this.runtime.emitEvent("FORM_FIELD_CANCELLED", {
1188
+ runtime: this.runtime,
1189
+ sessionId,
1190
+ entityId,
1191
+ field,
1192
+ reason
1193
+ });
1194
+ } catch (_error) {
1195
+ logger.debug(`[FormService] No event handler for FORM_FIELD_CANCELLED`);
1196
+ }
1197
+ logger.info(`[FormService] Cancelled external field ${field}: ${reason}`);
1198
+ }
1199
+ // ============================================================================
1200
+ // LIFECYCLE
1201
+ // ============================================================================
1202
+ /**
1203
+ * Submit a form session
1204
+ */
1205
+ async submit(sessionId, entityId) {
1206
+ const session = await getSessionById(this.runtime, entityId, sessionId);
1207
+ if (!session) {
1208
+ throw new Error(`Session not found: ${sessionId}`);
1209
+ }
1210
+ const form = this.getForm(session.formId);
1211
+ if (!form) {
1212
+ throw new Error(`Form not found: ${session.formId}`);
1213
+ }
1214
+ if (!this.checkAllRequiredFilled(session, form)) {
1215
+ throw new Error("Not all required fields are filled");
1216
+ }
1217
+ const now = Date.now();
1218
+ const values = {};
1219
+ const mappedValues = {};
1220
+ const files = {};
1221
+ for (const control of form.controls) {
1222
+ const fieldState = session.fields[control.key];
1223
+ if (fieldState?.value !== void 0) {
1224
+ values[control.key] = fieldState.value;
1225
+ const dbKey = control.dbbind || control.key;
1226
+ mappedValues[dbKey] = fieldState.value;
1227
+ }
1228
+ if (fieldState?.files) {
1229
+ files[control.key] = fieldState.files;
1230
+ }
1231
+ }
1232
+ const submission = {
1233
+ id: uuidv42(),
1234
+ formId: session.formId,
1235
+ formVersion: session.formVersion,
1236
+ sessionId: session.id,
1237
+ entityId: session.entityId,
1238
+ values,
1239
+ mappedValues,
1240
+ files: Object.keys(files).length > 0 ? files : void 0,
1241
+ submittedAt: now,
1242
+ meta: session.meta
1243
+ };
1244
+ await saveSubmission(this.runtime, submission);
1245
+ await saveAutofillData(this.runtime, entityId, session.formId, values);
1246
+ session.status = "submitted";
1247
+ session.submittedAt = now;
1248
+ session.updatedAt = now;
1249
+ await saveSession(this.runtime, session);
1250
+ if (form.hooks?.onSubmit) {
1251
+ const submissionPayload = JSON.parse(JSON.stringify(submission));
1252
+ await this.executeHook(session, "onSubmit", {
1253
+ submission: submissionPayload
1254
+ });
1255
+ }
1256
+ logger.debug(`[FormService] Submitted session ${sessionId}`);
1257
+ return submission;
1258
+ }
1259
+ /**
1260
+ * Stash a session for later
1261
+ */
1262
+ async stash(sessionId, entityId) {
1263
+ const session = await getSessionById(this.runtime, entityId, sessionId);
1264
+ if (!session) {
1265
+ throw new Error(`Session not found: ${sessionId}`);
1266
+ }
1267
+ const form = this.getForm(session.formId);
1268
+ session.status = "stashed";
1269
+ session.updatedAt = Date.now();
1270
+ await saveSession(this.runtime, session);
1271
+ if (form?.hooks?.onCancel) {
1272
+ }
1273
+ logger.debug(`[FormService] Stashed session ${sessionId}`);
1274
+ }
1275
+ /**
1276
+ * Restore a stashed session
1277
+ */
1278
+ async restore(sessionId, entityId) {
1279
+ const session = await getSessionById(this.runtime, entityId, sessionId);
1280
+ if (!session) {
1281
+ throw new Error(`Session not found: ${sessionId}`);
1282
+ }
1283
+ if (session.status !== "stashed") {
1284
+ throw new Error(`Session is not stashed: ${session.status}`);
1285
+ }
1286
+ const existing = await getActiveSession(this.runtime, entityId, session.roomId);
1287
+ if (existing && existing.id !== sessionId) {
1288
+ throw new Error(`Active session already exists in room: ${existing.id}`);
1289
+ }
1290
+ session.status = "active";
1291
+ session.updatedAt = Date.now();
1292
+ session.expiresAt = this.calculateTTL(session);
1293
+ await saveSession(this.runtime, session);
1294
+ logger.debug(`[FormService] Restored session ${sessionId}`);
1295
+ return session;
1296
+ }
1297
+ /**
1298
+ * Cancel a session
1299
+ */
1300
+ async cancel(sessionId, entityId, force = false) {
1301
+ const session = await getSessionById(this.runtime, entityId, sessionId);
1302
+ if (!session) {
1303
+ throw new Error(`Session not found: ${sessionId}`);
1304
+ }
1305
+ if (!force && this.shouldConfirmCancel(session) && !session.cancelConfirmationAsked) {
1306
+ session.cancelConfirmationAsked = true;
1307
+ session.updatedAt = Date.now();
1308
+ await saveSession(this.runtime, session);
1309
+ return false;
1310
+ }
1311
+ const form = this.getForm(session.formId);
1312
+ session.status = "cancelled";
1313
+ session.updatedAt = Date.now();
1314
+ await saveSession(this.runtime, session);
1315
+ if (form?.hooks?.onCancel) {
1316
+ await this.executeHook(session, "onCancel");
1317
+ }
1318
+ logger.debug(`[FormService] Cancelled session ${sessionId}`);
1319
+ return true;
1320
+ }
1321
+ // ============================================================================
1322
+ // SUBMISSIONS
1323
+ // ============================================================================
1324
+ /**
1325
+ * Get submissions for entity, optionally filtered by form ID
1326
+ */
1327
+ async getSubmissions(entityId, formId) {
1328
+ return getSubmissions(this.runtime, entityId, formId);
1329
+ }
1330
+ // ============================================================================
1331
+ // AUTOFILL
1332
+ // ============================================================================
1333
+ /**
1334
+ * Get autofill data for a form
1335
+ */
1336
+ async getAutofill(entityId, formId) {
1337
+ const data = await getAutofillData(this.runtime, entityId, formId);
1338
+ return data?.values || null;
1339
+ }
1340
+ /**
1341
+ * Apply autofill to a session
1342
+ */
1343
+ async applyAutofill(session) {
1344
+ const form = this.getForm(session.formId);
1345
+ if (!form?.ux?.allowAutofill) {
1346
+ return [];
1347
+ }
1348
+ const autofill = await getAutofillData(this.runtime, session.entityId, session.formId);
1349
+ if (!autofill) {
1350
+ return [];
1351
+ }
1352
+ const appliedFields = [];
1353
+ const now = Date.now();
1354
+ for (const control of form.controls) {
1355
+ if (session.fields[control.key]?.status !== "empty") {
1356
+ continue;
1357
+ }
1358
+ const value = autofill.values[control.key];
1359
+ if (value !== void 0) {
1360
+ session.fields[control.key] = {
1361
+ status: "filled",
1362
+ value,
1363
+ source: "autofill",
1364
+ updatedAt: now
1365
+ };
1366
+ appliedFields.push(control.key);
1367
+ }
1368
+ }
1369
+ if (appliedFields.length > 0) {
1370
+ session.updatedAt = now;
1371
+ await saveSession(this.runtime, session);
1372
+ }
1373
+ return appliedFields;
1374
+ }
1375
+ // ============================================================================
1376
+ // CONTEXT HELPERS
1377
+ // ============================================================================
1378
+ /**
1379
+ * Get session context for provider
1380
+ */
1381
+ getSessionContext(session) {
1382
+ const form = this.getForm(session.formId);
1383
+ if (!form) {
1384
+ return {
1385
+ hasActiveForm: false,
1386
+ progress: 0,
1387
+ filledFields: [],
1388
+ missingRequired: [],
1389
+ uncertainFields: [],
1390
+ nextField: null,
1391
+ pendingExternalFields: []
1392
+ };
1393
+ }
1394
+ const filledFields = [];
1395
+ const missingRequired = [];
1396
+ const uncertainFields = [];
1397
+ const pendingExternalFields = [];
1398
+ let nextField = null;
1399
+ let filledCount = 0;
1400
+ let totalRequired = 0;
1401
+ for (const control of form.controls) {
1402
+ if (control.hidden) continue;
1403
+ const fieldState = session.fields[control.key];
1404
+ if (control.required) {
1405
+ totalRequired++;
1406
+ }
1407
+ if (fieldState?.status === "filled") {
1408
+ filledCount++;
1409
+ filledFields.push({
1410
+ key: control.key,
1411
+ label: control.label,
1412
+ displayValue: formatValue(fieldState.value ?? null, control)
1413
+ });
1414
+ } else if (fieldState?.status === "pending") {
1415
+ if (fieldState.externalState) {
1416
+ pendingExternalFields.push({
1417
+ key: control.key,
1418
+ label: control.label,
1419
+ instructions: fieldState.externalState.instructions || "Waiting for confirmation...",
1420
+ reference: fieldState.externalState.reference || "",
1421
+ activatedAt: fieldState.externalState.activatedAt || Date.now(),
1422
+ address: fieldState.externalState.address
1423
+ });
1424
+ }
1425
+ } else if (fieldState?.status === "uncertain") {
1426
+ uncertainFields.push({
1427
+ key: control.key,
1428
+ label: control.label,
1429
+ value: fieldState.value ?? null,
1430
+ confidence: fieldState.confidence ?? 0
1431
+ });
1432
+ } else if (fieldState?.status === "invalid") {
1433
+ missingRequired.push({
1434
+ key: control.key,
1435
+ label: control.label,
1436
+ description: control.description,
1437
+ askPrompt: control.askPrompt
1438
+ });
1439
+ if (!nextField) nextField = control;
1440
+ } else if (control.required && fieldState?.status !== "skipped") {
1441
+ missingRequired.push({
1442
+ key: control.key,
1443
+ label: control.label,
1444
+ description: control.description,
1445
+ askPrompt: control.askPrompt
1446
+ });
1447
+ if (!nextField) nextField = control;
1448
+ } else if (!nextField && fieldState?.status === "empty") {
1449
+ nextField = control;
1450
+ }
1451
+ }
1452
+ const progress = totalRequired > 0 ? Math.round(filledCount / totalRequired * 100) : 100;
1453
+ return {
1454
+ hasActiveForm: true,
1455
+ formId: session.formId,
1456
+ formName: form.name,
1457
+ progress,
1458
+ filledFields,
1459
+ missingRequired,
1460
+ uncertainFields,
1461
+ nextField,
1462
+ status: session.status,
1463
+ pendingCancelConfirmation: session.cancelConfirmationAsked && session.status === "active",
1464
+ pendingExternalFields
1465
+ };
1466
+ }
1467
+ /**
1468
+ * Get current values from session
1469
+ */
1470
+ getValues(session) {
1471
+ const values = {};
1472
+ for (const [key, state] of Object.entries(session.fields)) {
1473
+ if (state.value !== void 0) {
1474
+ values[key] = state.value;
1475
+ }
1476
+ }
1477
+ return values;
1478
+ }
1479
+ /**
1480
+ * Get mapped values (using dbbind)
1481
+ */
1482
+ getMappedValues(session) {
1483
+ const form = this.getForm(session.formId);
1484
+ if (!form) return {};
1485
+ const values = {};
1486
+ for (const control of form.controls) {
1487
+ const state = session.fields[control.key];
1488
+ if (state?.value !== void 0) {
1489
+ const key = control.dbbind || control.key;
1490
+ values[key] = state.value;
1491
+ }
1492
+ }
1493
+ return values;
1494
+ }
1495
+ // ============================================================================
1496
+ // TTL & EFFORT
1497
+ // ============================================================================
1498
+ /**
1499
+ * Calculate TTL based on effort
1500
+ */
1501
+ calculateTTL(session) {
1502
+ const form = this.getForm(session.formId);
1503
+ const config = form?.ttl || {};
1504
+ const minDays = config.minDays ?? 14;
1505
+ const maxDays = config.maxDays ?? 90;
1506
+ const multiplier = config.effortMultiplier ?? 0.5;
1507
+ const minutesSpent = session.effort.timeSpentMs / 6e4;
1508
+ const effortDays = minutesSpent * multiplier;
1509
+ const ttlDays = Math.min(maxDays, Math.max(minDays, effortDays));
1510
+ return Date.now() + ttlDays * 24 * 60 * 60 * 1e3;
1511
+ }
1512
+ /**
1513
+ * Check if cancel should require confirmation
1514
+ */
1515
+ shouldConfirmCancel(session) {
1516
+ const minEffortMs = 5 * 60 * 1e3;
1517
+ return session.effort.timeSpentMs > minEffortMs;
1518
+ }
1519
+ // ============================================================================
1520
+ // HOOKS
1521
+ // ============================================================================
1522
+ /**
1523
+ * Execute a form hook
1524
+ */
1525
+ async executeHook(session, hookName, options) {
1526
+ const form = this.getForm(session.formId);
1527
+ const workerName = form?.hooks?.[hookName];
1528
+ if (!workerName) return;
1529
+ const worker = this.runtime.getTaskWorker(workerName);
1530
+ if (!worker) {
1531
+ logger.warn(`[FormService] Hook worker not found: ${workerName}`);
1532
+ return;
1533
+ }
1534
+ try {
1535
+ const task = {
1536
+ id: session.id,
1537
+ name: workerName,
1538
+ roomId: session.roomId,
1539
+ entityId: session.entityId,
1540
+ tags: []
1541
+ };
1542
+ await worker.execute(
1543
+ this.runtime,
1544
+ {
1545
+ session,
1546
+ form,
1547
+ ...options
1548
+ },
1549
+ task
1550
+ );
1551
+ } catch (error) {
1552
+ logger.error(`[FormService] Hook execution failed: ${hookName}`, String(error));
1553
+ }
1554
+ }
1555
+ // ============================================================================
1556
+ // HELPERS
1557
+ // ============================================================================
1558
+ /**
1559
+ * Check if all required fields are filled
1560
+ */
1561
+ checkAllRequiredFilled(session, form) {
1562
+ for (const control of form.controls) {
1563
+ if (!control.required) continue;
1564
+ const fieldState = session.fields[control.key];
1565
+ if (!fieldState || fieldState.status === "empty" || fieldState.status === "invalid") {
1566
+ return false;
1567
+ }
1568
+ }
1569
+ return true;
1570
+ }
1571
+ };
1572
+ function prettify(key) {
1573
+ return key.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
1574
+ }
1575
+
1576
+ export {
1577
+ FORM_CONTROL_DEFAULTS,
1578
+ FORM_DEFINITION_DEFAULTS,
1579
+ FORM_SESSION_COMPONENT,
1580
+ FORM_SUBMISSION_COMPONENT,
1581
+ FORM_AUTOFILL_COMPONENT,
1582
+ BUILTIN_TYPES,
1583
+ BUILTIN_TYPE_MAP,
1584
+ registerBuiltinTypes,
1585
+ getBuiltinType,
1586
+ isBuiltinType,
1587
+ getActiveSession,
1588
+ getAllActiveSessions,
1589
+ getStashedSessions,
1590
+ saveSession,
1591
+ deleteSession,
1592
+ saveSubmission,
1593
+ getSubmissions,
1594
+ getAutofillData,
1595
+ saveAutofillData,
1596
+ FormService
1597
+ };