@elizaos/plugin-forms
Version:
Forms plugin for ElizaOS - structured conversational data collection
1,482 lines (1,464 loc) • 139 kB
JavaScript
// src/services/forms-service.ts
import {
asUUID,
logger,
ModelType,
Service,
encryptStringValue,
decryptStringValue,
getSalt
} from "@elizaos/core";
// node_modules/uuid/dist/esm/stringify.js
var byteToHex = [];
for (let i = 0;i < 256; ++i) {
byteToHex.push((i + 256).toString(16).slice(1));
}
function unsafeStringify(arr, offset = 0) {
return (byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + "-" + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + "-" + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + "-" + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + "-" + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]]).toLowerCase();
}
// node_modules/uuid/dist/esm/rng.js
import { randomFillSync } from "crypto";
var rnds8Pool = new Uint8Array(256);
var poolPtr = rnds8Pool.length;
function rng() {
if (poolPtr > rnds8Pool.length - 16) {
randomFillSync(rnds8Pool);
poolPtr = 0;
}
return rnds8Pool.slice(poolPtr, poolPtr += 16);
}
// node_modules/uuid/dist/esm/native.js
import { randomUUID } from "crypto";
var native_default = { randomUUID };
// node_modules/uuid/dist/esm/v4.js
function v4(options, buf, offset) {
if (native_default.randomUUID && !buf && !options) {
return native_default.randomUUID();
}
options = options || {};
const rnds = options.random ?? options.rng?.() ?? rng();
if (rnds.length < 16) {
throw new Error("Random bytes length must be >= 16");
}
rnds[6] = rnds[6] & 15 | 64;
rnds[8] = rnds[8] & 63 | 128;
if (buf) {
offset = offset || 0;
if (offset < 0 || offset + 16 > buf.length) {
throw new RangeError(`UUID byte range ${offset}:${offset + 15} is out of buffer bounds`);
}
for (let i = 0;i < 16; ++i) {
buf[offset + i] = rnds[i];
}
return buf;
}
return unsafeStringify(rnds);
}
var v4_default = v4;
// src/services/forms-service.ts
import { z } from "zod";
var FORMS_CLEANUP_INTERVAL = 60 * 60 * 1000;
var FormStatusSchema = z.enum(["active", "completed", "cancelled"]);
var FormFieldTypeSchema = z.enum([
"text",
"number",
"email",
"tel",
"url",
"textarea",
"choice",
"checkbox",
"date",
"time",
"datetime"
]);
var FormStepSchema = z.object({
id: z.string(),
name: z.string(),
completed: z.boolean().optional().default(false)
});
var DatabaseFormRowSchema = z.object({
id: z.string().uuid(),
agent_id: z.string().uuid(),
name: z.string(),
description: z.string().nullable(),
status: FormStatusSchema,
current_step_index: z.number().int().min(0),
steps: z.string().transform((val, ctx) => {
try {
const parsed = JSON.parse(val);
const result = z.array(FormStepSchema).safeParse(parsed);
if (!result.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid steps format"
});
return z.NEVER;
}
return result.data;
} catch (e) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid JSON in steps"
});
return z.NEVER;
}
}),
created_at: z.union([z.string(), z.date()]).transform((val) => new Date(val).getTime()),
updated_at: z.union([z.string(), z.date()]).transform((val) => new Date(val).getTime()),
completed_at: z.union([z.string(), z.date()]).nullable().transform((val) => val ? new Date(val).getTime() : undefined),
metadata: z.string().nullable().transform((val, ctx) => {
if (!val)
return {};
try {
return JSON.parse(val);
} catch (e) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid JSON in metadata"
});
return {};
}
})
});
var DatabaseFieldRowSchema = z.object({
field_id: z.string(),
step_id: z.string(),
label: z.string(),
type: FormFieldTypeSchema,
value: z.string().nullable(),
is_secret: z.union([z.boolean(), z.number()]).transform((val) => Boolean(val)),
is_optional: z.union([z.boolean(), z.number()]).transform((val) => Boolean(val)),
description: z.string().nullable(),
criteria: z.string().nullable(),
error: z.string().nullable(),
metadata: z.string().nullable().transform((val, ctx) => {
if (!val)
return;
try {
return JSON.parse(val);
} catch (_e) {
return;
}
})
});
var createFieldValueSchema = (type) => {
switch (type) {
case "email":
return z.string().email();
case "url":
return z.string().url();
case "number":
return z.number();
case "checkbox":
return z.boolean();
case "tel":
return z.string().min(7);
case "date":
case "time":
case "datetime":
return z.string().min(1);
default:
return z.union([z.string(), z.number(), z.boolean()]);
}
};
class FormsService extends Service {
static serviceName = "forms";
static serviceType = "forms";
forms = new Map;
templates = new Map;
persistenceTimer;
cleanupTimer;
tablesChecked = false;
tablesExist = false;
capabilityDescription = "Form management service for collecting structured data from users";
constructor(runtime) {
super(runtime);
this.initialize(runtime);
}
static async start(runtime) {
const service = new FormsService(runtime);
return service;
}
async initialize(_runtime) {
this.registerDefaultTemplates();
await this.checkDatabaseTables();
if (this.tablesExist) {
await this.restorePersistedForms();
} else {
logger.warn("Forms database tables not found. Persistence disabled until tables are created.");
}
this.persistenceTimer = setInterval(() => {
this.persistFormsBatch().catch((err) => logger.error("Failed to persist forms:", err));
}, 30000);
this.cleanupTimer = setInterval(() => {
this.cleanupOldForms().catch((err) => logger.error("Failed to cleanup forms:", err));
}, FORMS_CLEANUP_INTERVAL);
}
registerDefaultTemplates() {
this.templates.set("contact", {
name: "contact",
description: "Basic contact information form",
steps: [
{
id: "basic-info",
name: "Basic Information",
fields: [
{
id: "name",
label: "Name",
type: "text",
description: "Your full name",
criteria: "First and last name"
},
{
id: "email",
label: "Email",
type: "email",
description: "Your email address",
criteria: "Valid email format"
},
{
id: "message",
label: "Message",
type: "textarea",
description: "Your message",
optional: true
}
]
}
]
});
}
async forcePersist() {
if (this.tablesExist) {
await this.persistFormsBatch();
}
}
async waitForTables(maxAttempts = 10, delayMs = 1000) {
for (let i = 0;i < maxAttempts; i++) {
if (!this.tablesChecked) {
await this.checkDatabaseTables();
}
if (this.tablesExist) {
return true;
}
if (i < maxAttempts - 1) {
logger.debug(`Waiting for forms tables... attempt ${i + 1}/${maxAttempts}`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
this.tablesChecked = false;
}
}
logger.warn("Forms tables not available after waiting");
return false;
}
isPersistenceAvailable() {
return this.tablesExist;
}
async createForm(templateOrForm, metadata) {
let form;
if (typeof templateOrForm === "string") {
const template = this.templates.get(templateOrForm);
if (!template) {
throw new Error(`Template "${templateOrForm}" not found`);
}
form = {
id: asUUID(v4_default()),
agentId: this.runtime.agentId,
name: template.name,
description: template.description,
steps: template.steps.map((step) => ({
...step,
completed: false,
fields: step.fields.map((field) => ({ ...field }))
})),
currentStepIndex: 0,
status: "active",
createdAt: Date.now(),
updatedAt: Date.now(),
metadata: metadata || {}
};
} else {
form = {
id: asUUID(v4_default()),
agentId: this.runtime.agentId,
status: "active",
currentStepIndex: 0,
createdAt: Date.now(),
updatedAt: Date.now(),
...templateOrForm,
steps: templateOrForm.steps.map((step) => ({
...step,
completed: false
}))
};
}
this.forms.set(form.id, form);
logger.debug(`Created form ${form.id} (${form.name})`);
return form;
}
async updateForm(formId, message) {
const form = this.forms.get(formId);
if (!form) {
return {
success: false,
message: "Form not found"
};
}
if (form.status !== "active") {
return {
success: false,
message: "Form is not active"
};
}
const currentStep = form.steps[form.currentStepIndex];
if (!currentStep) {
return {
success: false,
message: "No current step"
};
}
const extractionResult = await this.extractFormValues(message.content.text || "", currentStep.fields.filter((f) => {
const hasValue = f.value !== undefined && f.value !== null && f.value !== "";
if (hasValue)
return false;
if (f.optional) {
const messageText = message.content.text?.toLowerCase() || "";
const fieldLabel = f.label.toLowerCase();
return messageText.includes(fieldLabel) || messageText.includes(fieldLabel.replace(/\s+/g, "")) || messageText.includes(fieldLabel.replace(/\s+/g, "_"));
}
return true;
}));
const updatedFields = [];
const errors = [];
for (const [fieldId, value] of Object.entries(extractionResult.values)) {
const field = currentStep.fields.find((f) => f.id === fieldId);
if (field) {
if (value !== null && value !== undefined) {
const validatedValue = this.validateFieldValue(value, field);
if (validatedValue.isValid) {
if (field.secret && typeof validatedValue.value === "string") {
const salt = getSalt();
field.value = encryptStringValue(validatedValue.value, salt);
} else {
field.value = validatedValue.value;
}
field.error = undefined;
updatedFields.push(fieldId);
logger.debug(`Updated field ${fieldId} with value:`, field.secret ? "[REDACTED]" : validatedValue.value);
} else {
field.error = validatedValue.error;
errors.push({ fieldId, message: validatedValue.error || "Invalid value" });
logger.warn(`Invalid value for field ${fieldId}: ${validatedValue.error}`);
}
}
}
}
const requiredFields = currentStep.fields.filter((f) => !f.optional);
const filledRequiredFields = requiredFields.filter((f) => f.value !== undefined && f.value !== null);
const stepCompleted = filledRequiredFields.length === requiredFields.length;
let formCompleted = false;
let responseMessage = "";
if (stepCompleted) {
currentStep.completed = true;
if (currentStep.onComplete) {
try {
await currentStep.onComplete(form, currentStep.id);
} catch (error) {
logger.error(`Error in step callback for ${currentStep.id}:`, error);
}
}
if (form.currentStepIndex < form.steps.length - 1) {
form.currentStepIndex++;
responseMessage = `Step "${currentStep.name}" completed. Moving to step "${form.steps[form.currentStepIndex].name}".`;
} else {
form.status = "completed";
formCompleted = true;
responseMessage = "Form completed successfully!";
if (form.onComplete) {
try {
await form.onComplete(form);
} catch (error) {
logger.error("Error in form completion callback:", error);
}
}
}
} else {
const missingRequired = requiredFields.filter((f) => !f.value);
if (missingRequired.length > 0) {
responseMessage = `Please provide: ${missingRequired.map((f) => f.label).join(", ")}`;
}
}
form.updatedAt = Date.now();
return {
success: true,
form,
updatedFields,
errors,
stepCompleted,
formCompleted,
message: responseMessage
};
}
async extractFormValues(text, fields) {
if (fields.length === 0) {
return { values: {} };
}
const prompt = `Extract values for the following form fields from the user's message.
Return a JSON object with field IDs as keys and extracted values.
If a value is not found, omit it from the result.
User message: "${text}"
Fields to extract:
${fields.map((f) => `- ${f.id} (${f.label}): ${f.description || f.type}${f.criteria ? ` - Criteria: ${f.criteria}` : ""}`).join(`
`)}
Return only valid JSON with extracted values. Be precise and extract only what is explicitly stated.`;
try {
const response = await this.runtime.useModel(ModelType.TEXT_SMALL, {
messages: [
{
role: "system",
content: "You are a precise form field extractor. Extract only values that are explicitly stated in the text."
},
{
role: "user",
content: prompt
}
],
temperature: 0.1,
max_tokens: 500
});
const content = typeof response === "string" ? response.trim() : "{}";
let jsonStr = content;
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
jsonStr = jsonMatch[0];
}
let values = {};
const fieldSchemas = {};
for (const field of fields) {
switch (field.type) {
case "number":
fieldSchemas[field.id] = z.union([z.number(), z.string()]).transform((val) => Number(val)).optional();
break;
case "checkbox":
fieldSchemas[field.id] = z.union([z.boolean(), z.string(), z.number()]).transform((val) => val === true || val === "true" || val === 1 || val === "1").optional();
break;
case "email":
fieldSchemas[field.id] = z.string().email().optional();
break;
case "url":
fieldSchemas[field.id] = z.string().url().optional();
break;
default:
fieldSchemas[field.id] = z.string().optional();
}
}
const LLMResponseSchema = z.object(fieldSchemas).passthrough();
try {
const parsed = JSON.parse(jsonStr);
const result = LLMResponseSchema.safeParse(parsed);
if (!result.success) {
logger.warn("Invalid LLM response format:", result.error.format());
return { values: {} };
}
values = result.data;
} catch (parseError) {
logger.error("Failed to parse LLM JSON response:", parseError);
return { values: {} };
}
logger.debug("Validated and extracted form values:", values);
return { values };
} catch (error) {
logger.error("Error extracting form values:", error);
return { values: {} };
}
}
async listForms(status) {
const forms = [];
for (const form of this.forms.values()) {
if (form.agentId === this.runtime.agentId) {
if (!status || form.status === status) {
forms.push(form);
}
}
}
return forms;
}
async getForm(formId) {
const form = this.forms.get(formId);
return form && form.agentId === this.runtime.agentId ? form : null;
}
async cancelForm(formId) {
const form = this.forms.get(formId);
if (!form || form.agentId !== this.runtime.agentId) {
return false;
}
form.status = "cancelled";
form.updatedAt = Date.now();
logger.debug(`Cancelled form ${formId}`);
return true;
}
registerTemplate(template) {
this.templates.set(template.name, template);
logger.debug(`Registered form template: ${template.name}`);
}
getTemplates() {
return Array.from(this.templates.values());
}
async cleanup(olderThanMs = 24 * 60 * 60 * 1000) {
const now = Date.now();
let removed = 0;
for (const [id, form] of this.forms.entries()) {
const age = now - form.updatedAt;
if (form.status === "completed" && age > 60 * 60 * 1000) {
this.forms.delete(id);
removed++;
logger.info(`Cleaned up completed form ${id}`);
} else if (form.status !== "active" && age > olderThanMs) {
this.forms.delete(id);
removed++;
logger.info(`Cleaned up old form ${id}`);
}
}
if (removed > 0) {
logger.debug(`Cleaned up ${removed} old forms`);
await this.persistForms();
}
return removed;
}
async stop() {
await this.persistFormsBatch();
if (this.persistenceTimer) {
clearInterval(this.persistenceTimer);
this.persistenceTimer = undefined;
}
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = undefined;
}
}
async checkDatabaseTables() {
if (this.tablesChecked)
return;
try {
const db = this.runtime;
if (!db.getDatabase) {
logger.debug("Database adapter not available");
return;
}
const database = await db.getDatabase();
if (!database) {
logger.debug("Database not available");
return;
}
try {
const result = await database.get(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'forms'
) as exists
`);
this.tablesExist = result?.exists || false;
if (!this.tablesExist) {
const schemaResult = await database.get(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema LIKE '%forms%' AND table_name = 'forms'
) as exists
`);
this.tablesExist = schemaResult?.exists || false;
}
} catch (error) {
logger.debug("Could not check for forms tables:", error);
this.tablesExist = false;
}
this.tablesChecked = true;
logger.info(`Forms database tables ${this.tablesExist ? "found" : "not found"}`);
} catch (error) {
logger.error("Error checking database tables:", error);
this.tablesChecked = true;
this.tablesExist = false;
}
}
async persistFormsBatch() {
try {
if (!this.tablesExist) {
return;
}
const db = this.runtime;
if (!db.getDatabase) {
return;
}
const database = await db.getDatabase();
if (!database) {
return;
}
const formsToPersist = [];
for (const [formId, form] of this.forms.entries()) {
if (form.agentId === this.runtime.agentId) {
formsToPersist.push({ form, formId });
}
}
if (formsToPersist.length === 0) {
return;
}
try {
await database.run("BEGIN");
for (const { form } of formsToPersist) {
const formData = {
id: form.id,
agentId: form.agentId,
name: form.name,
description: form.description || null,
status: form.status,
currentStepIndex: form.currentStepIndex,
steps: JSON.stringify(form.steps.map((step) => ({
id: step.id,
name: step.name,
completed: step.completed
}))),
createdAt: new Date(form.createdAt),
updatedAt: new Date(form.updatedAt),
completedAt: form.completedAt ? new Date(form.completedAt) : null,
metadata: JSON.stringify(form.metadata || {})
};
await database.run(`
INSERT INTO forms (id, agent_id, name, description, status, current_step_index, steps, created_at, updated_at, completed_at, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
status = EXCLUDED.status,
current_step_index = EXCLUDED.current_step_index,
steps = EXCLUDED.steps,
updated_at = EXCLUDED.updated_at,
completed_at = EXCLUDED.completed_at,
metadata = EXCLUDED.metadata
`, [
formData.id,
formData.agentId,
formData.name,
formData.description,
formData.status,
formData.currentStepIndex,
formData.steps,
formData.createdAt,
formData.updatedAt,
formData.completedAt,
formData.metadata
]);
for (const step of form.steps) {
for (const field of step.fields) {
const fieldData = {
formId: form.id,
stepId: step.id,
fieldId: field.id,
label: field.label,
type: field.type,
value: field.value !== undefined && field.value !== null ? String(field.value) : null,
isSecret: field.secret || false,
isOptional: field.optional || false,
description: field.description || null,
criteria: field.criteria || null,
error: field.error || null,
metadata: JSON.stringify(field.metadata || {})
};
await database.run(`
INSERT INTO form_fields (form_id, step_id, field_id, label, type, value, is_secret, is_optional, description, criteria, error, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
ON CONFLICT(form_id, step_id, field_id) DO UPDATE SET
value = EXCLUDED.value,
error = EXCLUDED.error,
updated_at = datetime('now')
`, [
fieldData.formId,
fieldData.stepId,
fieldData.fieldId,
fieldData.label,
fieldData.type,
fieldData.value,
fieldData.isSecret,
fieldData.isOptional,
fieldData.description,
fieldData.criteria,
fieldData.error,
fieldData.metadata
]);
}
}
}
await database.run("COMMIT");
logger.debug(`Successfully persisted ${formsToPersist.length} forms in batch`);
} catch (error) {
try {
await database.run("ROLLBACK");
} catch (rollbackError) {
logger.error("Error rolling back batch transaction:", rollbackError);
}
if (error.message?.includes("does not exist") || error.message?.includes("no such table")) {
logger.warn("Forms tables do not exist. Marking tables as not found.");
this.tablesExist = false;
return;
}
logger.warn("Batch persistence failed, falling back to individual transactions:", error);
await this.persistFormsIndividual();
}
} catch (error) {
logger.error("Error in batch persistence:", error);
}
}
async persistFormsIndividual() {
await this.persistForms();
}
async persistForms() {
try {
if (!this.tablesExist) {
if (!this.tablesChecked || Math.random() < 0.1) {
await this.checkDatabaseTables();
}
if (!this.tablesExist) {
return;
}
}
const db = this.runtime;
if (!db.getDatabase) {
logger.warn("Database adapter not available for form persistence");
return;
}
const database = await db.getDatabase();
if (!database) {
logger.warn("Database not available for form persistence");
return;
}
for (const [formId, form] of this.forms.entries()) {
if (form.agentId !== this.runtime.agentId)
continue;
try {
await database.run("BEGIN");
const formData = {
id: form.id,
agentId: form.agentId,
name: form.name,
description: form.description || null,
status: form.status,
currentStepIndex: form.currentStepIndex,
steps: JSON.stringify(form.steps.map((step) => ({
id: step.id,
name: step.name,
completed: step.completed
}))),
createdAt: new Date(form.createdAt),
updatedAt: new Date(form.updatedAt),
completedAt: form.completedAt ? new Date(form.completedAt) : null,
metadata: JSON.stringify(form.metadata || {})
};
try {
await database.run(`
INSERT INTO forms (id, agent_id, name, description, status, current_step_index, steps, created_at, updated_at, completed_at, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
status = EXCLUDED.status,
current_step_index = EXCLUDED.current_step_index,
steps = EXCLUDED.steps,
updated_at = EXCLUDED.updated_at,
completed_at = EXCLUDED.completed_at,
metadata = EXCLUDED.metadata
`, [
formData.id,
formData.agentId,
formData.name,
formData.description,
formData.status,
formData.currentStepIndex,
formData.steps,
formData.createdAt,
formData.updatedAt,
formData.completedAt,
formData.metadata
]);
} catch (dbError) {
if (dbError.message?.includes("does not exist") || dbError.message?.includes("no such table")) {
logger.warn("Forms table does not exist. Marking tables as not found.");
this.tablesExist = false;
return;
}
throw dbError;
}
for (const step of form.steps) {
for (const field of step.fields) {
const fieldData = {
formId: form.id,
stepId: step.id,
fieldId: field.id,
label: field.label,
type: field.type,
value: field.value !== undefined && field.value !== null ? String(field.value) : null,
isSecret: field.secret || false,
isOptional: field.optional || false,
description: field.description || null,
criteria: field.criteria || null,
error: field.error || null,
metadata: JSON.stringify(field.metadata || {})
};
try {
await database.run(`
INSERT INTO form_fields (form_id, step_id, field_id, label, type, value, is_secret, is_optional, description, criteria, error, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
ON CONFLICT(form_id, step_id, field_id) DO UPDATE SET
value = EXCLUDED.value,
error = EXCLUDED.error,
updated_at = datetime('now')
`, [
fieldData.formId,
fieldData.stepId,
fieldData.fieldId,
fieldData.label,
fieldData.type,
fieldData.value,
fieldData.isSecret,
fieldData.isOptional,
fieldData.description,
fieldData.criteria,
fieldData.error,
fieldData.metadata
]);
} catch (dbError) {
if (dbError.message?.includes("does not exist") || dbError.message?.includes("no such table")) {
logger.warn("Form fields table does not exist. Marking tables as not found.");
this.tablesExist = false;
return;
}
throw dbError;
}
}
}
await database.run("COMMIT");
} catch (error) {
try {
await database.run("ROLLBACK");
} catch (rollbackError) {
logger.error(`Error rolling back transaction for form ${formId}:`, rollbackError);
}
logger.error(`Error persisting form ${formId}:`, error);
}
}
logger.debug(`Persisted forms to database`);
} catch (error) {
logger.error("Error persisting forms:", error);
}
}
async restorePersistedForms() {
try {
if (!this.tablesExist) {
return;
}
const db = this.runtime;
if (!db.getDatabase) {
logger.warn("Database adapter not available for form restoration");
return;
}
const database = await db.getDatabase();
if (!database) {
logger.warn("Database not available for form restoration");
return;
}
let formsResult;
try {
formsResult = await database.all(`
SELECT * FROM forms
WHERE agent_id = ? AND status != 'completed'
ORDER BY updated_at DESC
`, [this.runtime.agentId]);
} catch (error) {
if (error.message?.includes("does not exist") || error.message?.includes("no such table")) {
logger.debug("Forms table does not exist during restoration");
this.tablesExist = false;
return;
}
throw error;
}
if (!formsResult || formsResult.length === 0) {
logger.debug("No forms to restore from database");
return;
}
for (const formRow of formsResult) {
try {
const validatedForm = this.validateAndSanitizeFormData(formRow);
if (!validatedForm) {
logger.warn(`Skipping invalid form ${formRow.id}`);
continue;
}
const steps = validatedForm.steps;
let fieldsResult;
try {
fieldsResult = await database.all(`
SELECT * FROM form_fields
WHERE form_id = ?
ORDER BY step_id, field_id
`, [formRow.id]);
} catch (error) {
if (error.message?.includes("does not exist") || error.message?.includes("no such table")) {
logger.debug("Form fields table does not exist during restoration");
this.tablesExist = false;
return;
}
throw error;
}
const fieldsByStep = new Map;
for (const fieldRow of fieldsResult) {
if (!fieldsByStep.has(fieldRow.step_id)) {
fieldsByStep.set(fieldRow.step_id, []);
}
fieldsByStep.get(fieldRow.step_id).push(fieldRow);
}
const form = {
...validatedForm,
steps: steps.map((step) => ({
id: step.id,
name: step.name,
completed: step.completed || false,
fields: (fieldsByStep.get(step.id) || []).map((fieldRow) => {
const validatedField = this.validateAndSanitizeFieldData(fieldRow);
if (!validatedField) {
logger.warn(`Skipping invalid field ${fieldRow.field_id} in form ${formRow.id}`);
return null;
}
let fieldValue = fieldRow.value !== null ? this.parseFieldValue(fieldRow.value, fieldRow.type, fieldRow.is_secret) : undefined;
if (fieldValue !== undefined) {
const valueSchema = createFieldValueSchema(validatedField.type);
const valueResult = valueSchema.safeParse(fieldValue);
if (!valueResult.success) {
logger.warn(`Invalid field value after decryption for ${fieldRow.field_id}:`, valueResult.error.format());
fieldValue = undefined;
} else {
fieldValue = valueResult.data;
}
}
return {
...validatedField,
value: fieldValue
};
}).filter((field) => field !== null)
}))
};
const hasValidSteps = form.steps.some((step) => step.fields.length > 0);
if (hasValidSteps) {
this.forms.set(form.id, form);
logger.debug(`Restored form ${form.id} (${form.name}) from database`);
} else {
logger.warn(`Form ${form.id} has no valid steps/fields, skipping`);
}
} catch (error) {
logger.error(`Error restoring form ${formRow.id}:`, error);
}
}
logger.info(`Restored ${this.forms.size} forms from database`);
} catch (error) {
logger.error("Error restoring forms:", error);
}
}
validateAndSanitizeFormData(formRow) {
const result = DatabaseFormRowSchema.safeParse(formRow);
if (!result.success) {
logger.warn(`Invalid form data for ${formRow.id}:`, result.error.format());
return null;
}
const validated = result.data;
return {
id: validated.id,
agentId: validated.agent_id,
name: validated.name,
description: validated.description || undefined,
status: validated.status,
currentStepIndex: validated.current_step_index,
steps: validated.steps,
createdAt: validated.created_at,
updatedAt: validated.updated_at,
completedAt: validated.completed_at,
metadata: validated.metadata
};
}
validateAndSanitizeFieldData(fieldRow) {
const result = DatabaseFieldRowSchema.safeParse(fieldRow);
if (!result.success) {
logger.warn(`Invalid field data:`, result.error.format());
return null;
}
const validated = result.data;
return {
id: validated.field_id,
label: validated.label,
type: validated.type,
description: validated.description || undefined,
criteria: validated.criteria || undefined,
optional: validated.is_optional,
secret: validated.is_secret,
error: validated.error || undefined,
metadata: validated.metadata
};
}
parseFieldValue(value, type, isSecret = false) {
let processedValue = value;
if (isSecret && type !== "number" && type !== "checkbox") {
const salt = getSalt();
processedValue = decryptStringValue(value, salt);
}
switch (type) {
case "number":
return Number(processedValue);
case "checkbox":
return processedValue === "true";
default:
return processedValue;
}
}
validateFieldValue(value, field) {
switch (field.type) {
case "number":
const num = Number(value);
if (isNaN(num)) {
return { isValid: false, error: "Must be a valid number" };
}
return { isValid: true, value: num };
case "email":
if (typeof value !== "string" || !value.includes("@") || !value.includes(".")) {
return { isValid: false, error: "Must be a valid email address" };
}
return { isValid: true, value: value.trim() };
case "url":
if (typeof value !== "string" || !value.startsWith("http://") && !value.startsWith("https://")) {
return { isValid: false, error: "Must be a valid URL starting with http:// or https://" };
}
return { isValid: true, value: value.trim() };
case "tel":
if (typeof value !== "string" || value.length < 7) {
return { isValid: false, error: "Must be a valid phone number" };
}
return { isValid: true, value: value.trim() };
case "date":
case "time":
case "datetime":
if (typeof value !== "string" || !value) {
return { isValid: false, error: `Must be a valid ${field.type}` };
}
return { isValid: true, value: value.trim() };
case "checkbox":
return { isValid: true, value: Boolean(value) };
default:
if (value === null || value === undefined) {
return { isValid: false, error: "Value is required" };
}
return { isValid: true, value: String(value) };
}
}
_serializeForm(form) {
return {
...form,
onComplete: undefined,
steps: form.steps.map((step) => ({
...step,
onComplete: undefined
}))
};
}
_deserializeForm(data) {
return {
...data,
createdAt: new Date(data.createdAt).getTime(),
updatedAt: new Date(data.updatedAt).getTime(),
completedAt: data.completedAt ? new Date(data.completedAt).getTime() : undefined
};
}
async cleanupOldForms() {
await this.cleanup(24 * 60 * 60 * 1000);
}
}
// src/providers/forms-provider.ts
var formsProvider = {
name: "FORMS_CONTEXT",
description: "Provides context about active forms and their current state",
dynamic: true,
position: 50,
get: async (runtime, _message, _state) => {
const formsService = runtime.getService("forms");
if (!formsService) {
return {
text: "Forms service is not available.",
values: {},
data: {}
};
}
try {
const activeForms = await formsService.listForms("active");
if (activeForms.length === 0) {
return {
text: "",
values: {},
data: {}
};
}
let contextText = `[FORMS]
`;
const formsData = [];
for (const form of activeForms) {
const currentStep = form.steps[form.currentStepIndex];
contextText += `
Active Form: ${form.name} (ID: ${form.id})
`;
contextText += `Current Step: ${currentStep.name || currentStep.id}
`;
const completedFields = currentStep.fields.filter((f) => f.value !== undefined);
if (completedFields.length > 0) {
contextText += `Completed fields:
`;
completedFields.forEach((field) => {
const displayValue = field.secret ? "[SECRET]" : field.value;
contextText += ` - ${field.label}: ${displayValue}
`;
});
}
const remainingRequired = currentStep.fields.filter((f) => !f.optional && f.value === undefined);
if (remainingRequired.length > 0) {
contextText += `Required fields:
`;
remainingRequired.forEach((field) => {
contextText += ` - ${field.label}${field.description ? ` (${field.description})` : ""}
`;
});
}
const optionalFields = currentStep.fields.filter((f) => f.optional && f.value === undefined);
if (optionalFields.length > 0) {
contextText += `Optional fields:
`;
optionalFields.forEach((field) => {
contextText += ` - ${field.label}${field.description ? ` (${field.description})` : ""}
`;
});
}
contextText += `Progress: Step ${form.currentStepIndex + 1} of ${form.steps.length}
`;
formsData.push(form);
}
return {
text: contextText,
values: {
activeFormsCount: activeForms.length
},
data: {
forms: formsData
}
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return {
text: `Error retrieving forms context: ${errorMessage}`,
values: {},
data: {}
};
}
}
};
// src/actions/create-form.ts
import { logger as logger2 } from "@elizaos/core";
var createFormAction = {
name: "CREATE_FORM",
similes: ["START_FORM", "NEW_FORM", "INIT_FORM", "BEGIN_FORM"],
description: "Creates a new form from a template or custom definition",
validate: async (runtime, message, _state) => {
const formsService = runtime.getService("forms");
if (!formsService) {
return false;
}
const text = message.content.text?.toLowerCase() || "";
const wantsForm = text.includes("form") || text.includes("fill out") || text.includes("fill in") || text.includes("questionnaire") || text.includes("survey") || text.includes("contact") || text.includes("application");
return wantsForm;
},
handler: async (runtime, message, _state, options, callback) => {
try {
const formsService = runtime.getService("forms");
if (!formsService) {
throw new Error("Forms service not available");
}
const text = message.content.text?.toLowerCase() || "";
const templateName = options?.template || extractFormType(text) || "contact";
logger2.debug(`Creating form with template: ${templateName}`);
const form = await formsService.createForm(templateName, {
source: "user_request",
requestedAt: Date.now()
});
const firstStep = form.steps[0];
const requiredFields = firstStep?.fields.filter((f) => !f.optional) || [];
let responseText = `I've created a new ${form.name} form for you.`;
if (form.description) {
responseText += ` ${form.description}`;
}
if (firstStep) {
responseText += `
Let's start with ${firstStep.name}.`;
if (requiredFields.length > 0) {
responseText += `
Please provide the following information:`;
requiredFields.forEach((field) => {
responseText += `
- ${field.label}${field.description ? `: ${field.description}` : ""}`;
});
}
}
await callback?.({
text: responseText,
actions: ["CREATE_FORM"],
data: {
formId: form.id,
formName: form.name,
totalSteps: form.steps.length,
currentStep: 0
}
});
return {
success: true,
data: {
formId: form.id,
formName: form.name,
templateUsed: templateName
}
};
} catch (error) {
logger2.error("Error in CREATE_FORM action:", error);
await callback?.({
text: "I encountered an error while creating the form. Please try again.",
actions: []
});
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error"
};
}
},
examples: [
[
{
name: "user",
content: {
text: "I need to fill out a contact form"
}
},
{
name: "assistant",
content: {
text: `I've created a new contact form for you. Basic contact information form
Let's start with Basic Information.
Please provide the following information:
- Name: Your full name
- Email: Your email address`,
actions: ["CREATE_FORM"]
}
}
]
]
};
function extractFormType(text) {
const formTypes = {
contact: ["contact", "reach out", "get in touch", "message"],
feedback: ["feedback", "review", "opinion", "suggestion"],
application: ["apply", "application", "job", "position"],
survey: ["survey", "questionnaire", "poll"],
registration: ["register", "sign up", "enroll", "join"]
};
for (const [type, keywords] of Object.entries(formTypes)) {
if (keywords.some((keyword) => text.includes(keyword))) {
return type;
}
}
return null;
}
// src/actions/update-form.ts
import {
logger as logger3
} from "@elizaos/core";
var updateFormAction = {
name: "UPDATE_FORM",
similes: ["FILL_FORM", "SUBMIT_FORM", "COMPLETE_FORM", "FORM_INPUT"],
description: "Updates an active form with values extracted from the user message",
validate: async (runtime, message, _state) => {
const formsService = runtime.getService("forms");
if (!formsService) {
return false;
}
const activeForms = await formsService.listForms("active");
if (activeForms.length === 0) {
return false;
}
const text = message.content.text?.toLowerCase() || "";
const containsFormInput = text.includes("my name is") || text.includes("i am") || text.includes("@") || !!text.match(/\d{2,}/) || text.length > 5;
return containsFormInput;
},
handler: async (runtime, message, state, options, callback) => {
try {
const formsService = runtime.getService("forms");
if (!formsService) {
throw new Error("Forms service not available");
}
const activeForms = await formsService.listForms("active");
if (activeForms.length === 0) {
await callback?.({
text: "No active forms to update.",
actions: []
});
return;
}
let targetForm;
const specifiedFormId = options?.formId || state?.data?.activeFormId || state?.values?.activeFormId;
if (specifiedFormId) {
targetForm = activeForms.find((f) => f.id === specifiedFormId);
if (!targetForm) {
await callback?.({
text: "The specified form is no longer active.",
actions: []
});
return;
}
} else {
targetForm = activeForms[0];
}
const formId = targetForm.id;
logger3.debug(`Updating form ${formId} with user message`);
const result = await formsService.updateForm(formId, message);
if (!result.success) {
await callback?.({
text: result.message || "Failed to update form.",
actions: []
});
return {
success: false,
error: result.message || "Failed to update form."
};
}
let responseText = "";
if (result.updatedFields && result.updatedFields.length > 0) {
responseText += `Updated ${result.updatedFields.length} field(s). `;
}
if (result.formCompleted) {
responseText += "Form completed successfully! ";
} else if (result.stepCompleted) {
responseText += result.message || "";
} else {
responseText += result.message || "";
}
const form = result.form;
if (form && form.status === "active") {
const currentStep = form.steps[form.currentStepIndex];
if (currentStep) {
const remainingRequired = currentStep.fields.filter((f) => !f.optional && (!f.value || f.value === ""));
if (remainingRequired.length > 0) {
responseText += `
Remaining required fields in current step:`;
remainingRequired.forEach((field) => {
responseText += `
- ${field.label}${field.description ? `: ${field.description}` : ""}`;
});
}
}
}
await callback?.({
text: responseText.trim(),
actions: ["UPDATE_FORM"],
data: {
formId: form?.id,
formName: form?.name,
currentStep: form?.currentStepIndex,
totalSteps: form?.steps.length,
status: form?.status,
updatedFields: result.updatedFields
}
});
return {
success: true,
data: {
formId: form?.id,
updatedFields: result.updatedFields,
formCompleted: result.formCompleted,
stepCompleted: result.stepCompleted
}
};
} catch (error) {
logger3.error("Error in UPDATE_FORM action:", error);
await callback?.({
text: "An error occurred while updating the form. Please try again.",
actions: []
});
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error"
};
}
},
examples: [
[
{
name: "user",
content: {
text: "I need to fill out a contact form"
}
},
{
name: "assistant",
content: {
text: "I'll help you with the contact form. Please provide your name to get started.",
actions: ["CREATE_FORM"]
}
},
{
name: "user",
content: {
text: "My name is John Smith"
}
},
{
name: "assistant",
content: {
text: "Thank you, John Smith. I've recorded your name. Now, please provide your email address.",
actions: ["UPDATE_FORM"]
}
}
],
[
{
name: "user",
content: {
text: "john.smith@example.com"
}
},
{
name: "assistant",
content: {
text: "Perfect! I've recorded your email as john.smith@example.com. The last field is optional - would you like to include a message?",
actions: ["UPDATE_FORM"]
}
}
]
]
};
// src/actions/cancel-form.ts
import {
logger as logger4
} from "@elizaos/core";
var cancelFormAction = {
name: "CANCEL_FORM",
similes: ["ABORT_FORM", "STOP_FORM", "QUIT_FORM", "EXIT_FORM"],
description: "Cancels an active form",
validate: async (runtime, message, _state) => {
const formsService = runtime.getService("forms");
if (!formsService) {
return false;
}
const activeForms = await formsService.listForms("active");
if (activeForms.length === 0) {
return false;
}
const text = message.content.text?.toLowerCase() || "";
const wantsCancel = text.includes("cancel") || text.includes("stop") || text.includes("abort") || text.includes("quit") || text.includes("exit") || text.includes("nevermind") || text.includes("never mind") || text.includes("don't") && text.includes("want");
return wantsCancel;
},
handler: async (runtime, message, state, options, callback) => {
try {
const formsService = runtime.getService("forms");
if (!formsService) {
throw new Error("Forms service not available");