@ema.co/mcp-toolkit 1.5.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @ema.co/mcp-toolkit might be problematic. Click here for more details.

@@ -8,8 +8,12 @@ import { createVersionStorage } from "../sdk/version-storage.js";
8
8
  import { createVersionPolicyEngine } from "../sdk/version-policy.js";
9
9
  import { AGENT_CATALOG, WORKFLOW_PATTERNS, QUALIFYING_QUESTIONS, PLATFORM_CONCEPTS, WORKFLOW_EXECUTION_MODEL, COMMON_MISTAKES, DEBUG_CHECKLIST, GUIDANCE_TOPICS, VOICE_PERSONA_TEMPLATE, getAgentByName, getWidgetsForPersonaType, checkTypeCompatibility, getQualifyingQuestionsByCategory, getRequiredQualifyingQuestions, getConceptByTerm, suggestAgentsForUseCase, validateWorkflowPrompt, detectWorkflowIssues, validateWorkflowConnections, suggestWorkflowFixes, } from "../sdk/knowledge.js";
10
10
  import { compileWorkflow } from "../sdk/workflow-generator.js";
11
- import { parseInput, intentToSpec } from "../sdk/workflow-intent.js";
11
+ import { ensureActionRegistry } from "../sdk/action-registry.js";
12
+ import { parseInput, intentToSpec, generateWorkflow } from "../sdk/workflow-intent.js";
13
+ import { ensureSchemaRegistry, validateWorkflowSpec, generateActionCatalogForLLM } from "../sdk/workflow-validator.js";
14
+ import { runIntentArchitect } from "../sdk/intent-architect.js";
12
15
  import { analyzeExecutionFlow, generateASCIIFlow } from "../sdk/workflow-execution-analyzer.js";
16
+ import { SanitizationSession, sanitizePersona, buildConfirmationPrompt, detectWithPatterns, } from "../sdk/sanitizer.js";
13
17
  // ─────────────────────────────────────────────────────────────────────────────
14
18
  // Widget Validation Helpers
15
19
  // ─────────────────────────────────────────────────────────────────────────────
@@ -43,7 +47,7 @@ function validateWidgetsForApi(widgets) {
43
47
  // ═══════════════════════════════════════════════════════════════════════════
44
48
  // ENV Handler
45
49
  // ═══════════════════════════════════════════════════════════════════════════
46
- export async function handleEnv(_args, getEnvironments) {
50
+ export async function handleEnv(_args, getEnvironments, toolkit) {
47
51
  const envs = getEnvironments();
48
52
  return {
49
53
  environments: envs.map(e => ({
@@ -51,6 +55,7 @@ export async function handleEnv(_args, getEnvironments) {
51
55
  default: e.isDefault,
52
56
  })),
53
57
  count: envs.length,
58
+ toolkit: toolkit ?? { name: "unknown", version: "unknown" },
54
59
  };
55
60
  }
56
61
  // ═══════════════════════════════════════════════════════════════════════════
@@ -61,13 +66,41 @@ export async function handlePersona(args, client, getTemplateId, createClientFor
61
66
  const identifier = args.identifier; // deprecated alias
62
67
  const idOrName = id ?? identifier;
63
68
  const mode = args.mode;
69
+ // ═══════════════════════════════════════════════════════════════════════════
70
+ // UNIFIED HANDLING: Route workflow operations to handleWorkflow
71
+ // ═══════════════════════════════════════════════════════════════════════════
72
+ const input = args.input;
73
+ const optimize = args.optimize;
74
+ const workflowDef = args.workflow_def ?? args.workflow;
75
+ // If input is provided → this is a create/modify workflow operation
76
+ // If optimize is provided → this is an optimize operation
77
+ // If id + workflowDef provided → this is a deploy operation
78
+ // Route these to handleWorkflow
79
+ if (input || optimize || (idOrName && workflowDef)) {
80
+ // Map persona args to workflow args
81
+ const workflowArgs = {
82
+ ...args,
83
+ persona_id: idOrName, // workflow uses persona_id not id
84
+ };
85
+ delete workflowArgs.id;
86
+ delete workflowArgs.identifier;
87
+ return handleWorkflow(workflowArgs, client, getTemplateId);
88
+ }
89
+ // ═══════════════════════════════════════════════════════════════════════════
90
+ // Standard persona operations (get, list, compare, version management)
91
+ // ═══════════════════════════════════════════════════════════════════════════
64
92
  // Determine effective mode
93
+ // BUG FIX: clone_from and name+type now properly route to "create" mode
65
94
  let effectiveMode = mode;
66
95
  if (!effectiveMode) {
67
96
  if (args.templates)
68
97
  effectiveMode = "templates";
69
98
  else if (args.all || args.query || args.status || args.trigger_type)
70
99
  effectiveMode = "list";
100
+ else if (args.sanitize && idOrName)
101
+ effectiveMode = "sanitize"; // Standalone sanitize mode
102
+ else if (args.clone_from || (args.name && (args.type || args.template_id)))
103
+ effectiveMode = "create";
71
104
  else if (idOrName)
72
105
  effectiveMode = "get";
73
106
  else
@@ -151,18 +184,185 @@ export async function handlePersona(args, client, getTemplateId, createClientFor
151
184
  if (!templateId && args.type) {
152
185
  templateId = getTemplateId(args.type);
153
186
  }
187
+ // Check if cloning from dashboard persona
188
+ const cloneFrom = args.clone_from;
189
+ const cloneData = args.clone_data;
190
+ let sourcePersona = null;
191
+ let sourcePersonaType;
192
+ let sourceDashboardId;
193
+ if (cloneFrom && cloneData) {
194
+ try {
195
+ sourcePersona = await resolvePersona(client, cloneFrom);
196
+ if (sourcePersona) {
197
+ const protoConfig = sourcePersona.proto_config;
198
+ const projectSettings = protoConfig?.projectSettings;
199
+ const projectType = projectSettings?.projectType;
200
+ sourcePersonaType = projectType === 5 ? "voice" : projectType === 4 ? "chat" : projectType === 2 ? "dashboard" : undefined;
201
+ sourceDashboardId = sourcePersona.workflow_dashboard_id;
202
+ }
203
+ }
204
+ catch {
205
+ // Ignore errors in source persona detection
206
+ }
207
+ }
154
208
  const result = await client.createAiEmployee({
155
209
  name,
156
210
  description: args.description,
157
211
  template_id: templateId,
158
- source_persona_id: args.clone_from,
159
- clone_data: args.clone_data,
212
+ source_persona_id: cloneFrom,
213
+ clone_data: cloneData,
160
214
  });
161
- return {
215
+ const newPersonaId = result.persona_id ?? result.id;
216
+ // Automatically clone dashboard rows if source is a dashboard persona
217
+ let dashboardCloneResult;
218
+ if (sourcePersonaType === "dashboard" && sourceDashboardId && newPersonaId && cloneData) {
219
+ try {
220
+ // Dashboard operations require the persona to be enabled
221
+ // Auto-enable the persona temporarily for cloning
222
+ const newPersona = await client.getPersonaById(newPersonaId);
223
+ const newPersonaProtoConfig = newPersona?.proto_config;
224
+ // Enable the persona so we can add dashboard rows
225
+ await client.updateAiEmployee({
226
+ persona_id: newPersonaId,
227
+ proto_config: newPersonaProtoConfig ?? {},
228
+ enabled_by_user: true,
229
+ });
230
+ // Get source dashboard rows
231
+ const sourceRows = await client.getDashboardRows(sourceDashboardId, sourcePersona.id);
232
+ if (sourceRows.rows.length > 0) {
233
+ // Identify input columns
234
+ const inputColumns = sourceRows.schema.columns.filter(c => c.isInput);
235
+ // Clone each row
236
+ const results = [];
237
+ const shouldSanitize = args.sanitize;
238
+ const sanitizeExamples = args.sanitize_examples;
239
+ // Create sanitization session if needed
240
+ let sanitizationSession;
241
+ if (shouldSanitize) {
242
+ sanitizationSession = new SanitizationSession();
243
+ }
244
+ for (const row of sourceRows.rows) {
245
+ try {
246
+ // Build inputs from row's input column values
247
+ const inputs = [];
248
+ for (const inputCol of inputColumns) {
249
+ const colValue = row.columnValues.find(cv => cv.columnId === inputCol.columnId);
250
+ if (!colValue)
251
+ continue;
252
+ // Handle different column types
253
+ if (inputCol.columnType === "COLUMN_TYPE_DOCUMENT") {
254
+ // Skip document columns for now
255
+ continue;
256
+ }
257
+ else if (inputCol.columnType === "COLUMN_TYPE_STRING") {
258
+ let value = colValue.value.stringValue ?? "";
259
+ // Sanitize if enabled
260
+ if (sanitizationSession && value) {
261
+ const detected = detectWithPatterns(value);
262
+ for (const entity of detected) {
263
+ const replacement = sanitizationSession.getOrCreateReplacement(entity.value, entity.type);
264
+ value = value.split(entity.value).join(replacement);
265
+ }
266
+ if (sanitizeExamples) {
267
+ for (const example of sanitizeExamples) {
268
+ if (value.includes(example)) {
269
+ const replacement = sanitizationSession.getOrCreateReplacement(example, "unknown");
270
+ value = value.split(example).join(replacement);
271
+ }
272
+ }
273
+ }
274
+ }
275
+ inputs.push({ name: inputCol.name, string_value: value });
276
+ }
277
+ else if (inputCol.columnType === "COLUMN_TYPE_ARRAY") {
278
+ const arrayVals = colValue.value.arrayValue?.arrayValues ?? [];
279
+ if (arrayVals.length > 0) {
280
+ let value = arrayVals[0].stringValue ?? "";
281
+ if (sanitizationSession && value) {
282
+ const detected = detectWithPatterns(value);
283
+ for (const entity of detected) {
284
+ const replacement = sanitizationSession.getOrCreateReplacement(entity.value, entity.type);
285
+ value = value.split(entity.value).join(replacement);
286
+ }
287
+ }
288
+ inputs.push({ name: inputCol.name, string_value: value });
289
+ }
290
+ }
291
+ }
292
+ if (inputs.length === 0) {
293
+ results.push({ source_row_id: row.id, status: "skipped", error: "No clonable input values" });
294
+ continue;
295
+ }
296
+ // Upload the row to target dashboard
297
+ const uploadResult = await client.uploadAndRunDashboardRow(newPersonaId, inputs);
298
+ results.push({ source_row_id: row.id, target_row_id: uploadResult.row_id, status: "cloned" });
299
+ }
300
+ catch (err) {
301
+ results.push({ source_row_id: row.id, status: "error", error: err instanceof Error ? err.message : String(err) });
302
+ }
303
+ }
304
+ const clonedCount = results.filter(r => r.status === "cloned").length;
305
+ dashboardCloneResult = {
306
+ source_rows: sourceRows.rows.length,
307
+ cloned_rows: clonedCount,
308
+ skipped_rows: results.filter(r => r.status === "skipped").length,
309
+ error_rows: results.filter(r => r.status === "error").length,
310
+ sanitization_applied: !!shouldSanitize,
311
+ };
312
+ }
313
+ else {
314
+ dashboardCloneResult = { source_rows: 0, cloned_rows: 0, message: "Source dashboard was empty" };
315
+ }
316
+ }
317
+ catch (err) {
318
+ dashboardCloneResult = { error: `Dashboard clone failed: ${err instanceof Error ? err.message : String(err)}` };
319
+ }
320
+ }
321
+ // Handle sanitization for non-dashboard personas
322
+ const shouldSanitize = args.sanitize;
323
+ if (shouldSanitize && newPersonaId && sourcePersonaType !== "dashboard") {
324
+ const sanitizeResult = await sanitizePersonaById(client, newPersonaId, {
325
+ examples: args.sanitize_examples,
326
+ });
327
+ return {
328
+ success: true,
329
+ persona_id: newPersonaId,
330
+ name,
331
+ sanitization: sanitizeResult,
332
+ };
333
+ }
334
+ const createResult = {
162
335
  success: true,
163
- persona_id: result.persona_id,
336
+ persona_id: newPersonaId,
164
337
  name,
165
338
  };
339
+ if (sourcePersonaType) {
340
+ createResult.source_persona_type = sourcePersonaType;
341
+ }
342
+ if (dashboardCloneResult) {
343
+ createResult.dashboard_data_clone = dashboardCloneResult;
344
+ }
345
+ return createResult;
346
+ }
347
+ case "sanitize": {
348
+ // Standalone sanitize mode: sanitize an existing persona
349
+ if (!idOrName) {
350
+ return { error: "id required for sanitize mode" };
351
+ }
352
+ const persona = await resolvePersona(client, idOrName);
353
+ if (!persona) {
354
+ return { error: `Persona not found: ${idOrName}` };
355
+ }
356
+ const sanitizeResult = await sanitizePersonaById(client, persona.id, {
357
+ examples: args.sanitize_examples,
358
+ preview: args.preview,
359
+ });
360
+ return {
361
+ mode: "sanitize",
362
+ persona_id: persona.id,
363
+ persona_name: persona.name,
364
+ ...sanitizeResult,
365
+ };
166
366
  }
167
367
  case "update": {
168
368
  if (!idOrName) {
@@ -752,6 +952,8 @@ export async function handleWorkflow(args, client, getTemplateId) {
752
952
  if (!input) {
753
953
  return { error: "input required for generate mode" };
754
954
  }
955
+ // Load action registry for API-driven action versions/namespaces
956
+ const actionRegistry = await ensureActionRegistry(client);
755
957
  const parseResult = parseInput(input);
756
958
  if (!parseResult.validation.complete) {
757
959
  return {
@@ -761,10 +963,130 @@ export async function handleWorkflow(args, client, getTemplateId) {
761
963
  questions: parseResult.validation.questions,
762
964
  };
763
965
  }
764
- // Convert to spec and compile
765
- const spec = intentToSpec(parseResult.intent);
766
- const compiled = compileWorkflow(spec);
767
- // Validate the generated workflow
966
+ // Override persona_type from args.type if provided (args.type takes precedence)
967
+ if (args.type) {
968
+ parseResult.intent.persona_type = args.type;
969
+ }
970
+ // Load schema registry for API-driven validation (graceful degradation if unavailable)
971
+ let schemaRegistry;
972
+ try {
973
+ schemaRegistry = await ensureSchemaRegistry(client);
974
+ }
975
+ catch {
976
+ // Schema registry unavailable - skip API validation
977
+ schemaRegistry = null;
978
+ }
979
+ // ═══════════════════════════════════════════════════════════════════════════
980
+ // PROGRESSIVE ENHANCEMENT: Use Intent Architect for moderate/complex requests
981
+ // ═══════════════════════════════════════════════════════════════════════════
982
+ //
983
+ // Uses the canonical runIntentArchitect() entrypoint which:
984
+ // - Detects complexity with typed scores (not enums)
985
+ // - Returns qualification questions or LLM prompt
986
+ // - Supports iterative refinement with previous_answers
987
+ //
988
+ // Configure rollout via max_complexity option (default: allow all levels)
989
+ const maxComplexity = args.max_complexity || undefined;
990
+ const inputStr = input; // Already validated above
991
+ const architectResult = runIntentArchitect(inputStr, {
992
+ persona_type: parseResult.intent.persona_type,
993
+ available_integrations: schemaRegistry?.getAllActions().slice(0, 20).map(a => a.displayName || a.name),
994
+ }, { max_complexity: maxComplexity });
995
+ // For moderate/complex: return Intent Architect result with questions or prompt
996
+ if (!architectResult.strategy.can_proceed) {
997
+ let availableActions = [];
998
+ let availableTemplates = [];
999
+ // Enhance prompt with action catalog if available
1000
+ let enhancedPrompt = architectResult.prompt_package;
1001
+ if (schemaRegistry && enhancedPrompt) {
1002
+ const actionCatalog = generateActionCatalogForLLM(schemaRegistry);
1003
+ enhancedPrompt = {
1004
+ system: enhancedPrompt.system + "\n\n## Available Actions\n" + actionCatalog,
1005
+ user: enhancedPrompt.user,
1006
+ };
1007
+ availableActions = schemaRegistry.getAllActions().map(a => a.name);
1008
+ availableTemplates = schemaRegistry.getAllTemplates().map(t => ({ id: t.id, name: t.name, type: t.type }));
1009
+ }
1010
+ return {
1011
+ status: "needs_intent_architect",
1012
+ // Typed assessment (new)
1013
+ assessment: architectResult.assessment,
1014
+ // Strategy decision (new)
1015
+ strategy: architectResult.strategy,
1016
+ // Qualification questions (new)
1017
+ questions: architectResult.questions,
1018
+ // LLM prompt (if full architect)
1019
+ llm_prompt: enhancedPrompt,
1020
+ // Hint for what to do next
1021
+ hint: architectResult.strategy.next_step,
1022
+ // Legacy fields for backward compatibility
1023
+ reason: architectResult.legacy?.signals.reason,
1024
+ complexity: architectResult.legacy?.complexity,
1025
+ approach: architectResult.strategy.approach,
1026
+ gates_to_ask: architectResult.strategy.gates_to_ask,
1027
+ // Also provide basic spec as fallback
1028
+ fallback_spec: intentToSpec(parseResult.intent),
1029
+ // Include catalogs for reference (if available)
1030
+ available_actions: availableActions,
1031
+ available_templates: availableTemplates,
1032
+ };
1033
+ }
1034
+ // SIMPLE complexity: Check if intent requires LLM-driven generation (legacy path)
1035
+ const genResult = generateWorkflow(parseResult.intent);
1036
+ if (genResult.needs_llm) {
1037
+ // Complex workflow detected by legacy system - return prompt for LLM to generate
1038
+ // This is a fallback for cases the new complexity analyzer might miss
1039
+ let enhancedPrompt = genResult.llm_prompt;
1040
+ let availableActions = [];
1041
+ let availableTemplates = [];
1042
+ if (schemaRegistry) {
1043
+ const actionCatalog = generateActionCatalogForLLM(schemaRegistry);
1044
+ enhancedPrompt = genResult.llm_prompt ? {
1045
+ system: genResult.llm_prompt.system + "\n\n" + actionCatalog,
1046
+ user: genResult.llm_prompt.user,
1047
+ } : undefined;
1048
+ availableActions = schemaRegistry.getAllActions().map(a => a.name);
1049
+ availableTemplates = schemaRegistry.getAllTemplates().map(t => ({ id: t.id, name: t.name, type: t.type }));
1050
+ }
1051
+ return {
1052
+ status: "needs_llm_generation",
1053
+ reason: genResult.reason,
1054
+ complexity: genResult.complexity,
1055
+ llm_prompt: enhancedPrompt,
1056
+ hint: "Send llm_prompt to an LLM, then call persona(workflow_def=<parsed_response>) to deploy.",
1057
+ // Also provide basic spec as fallback (without complex chains)
1058
+ fallback_spec: intentToSpec(parseResult.intent),
1059
+ // Include catalogs for reference (if available)
1060
+ available_actions: availableActions,
1061
+ available_templates: availableTemplates,
1062
+ };
1063
+ }
1064
+ // Simple workflow - use generated spec directly
1065
+ const spec = genResult.spec;
1066
+ // Override spec name with provided name if given (args.name takes precedence over parsed name)
1067
+ if (args.name) {
1068
+ spec.name = args.name;
1069
+ }
1070
+ if (args.description) {
1071
+ spec.description = args.description;
1072
+ }
1073
+ // Validate spec against API schemas BEFORE compiling (if schema registry available)
1074
+ let specValidation = null;
1075
+ if (schemaRegistry) {
1076
+ specValidation = validateWorkflowSpec(spec, schemaRegistry);
1077
+ if (!specValidation.valid) {
1078
+ return {
1079
+ status: "validation_failed",
1080
+ errors: specValidation.errors,
1081
+ warnings: specValidation.warnings,
1082
+ action_coverage: specValidation.action_coverage,
1083
+ hint: "Fix the validation errors and try again. Unknown actions may need to be checked against ListActions.",
1084
+ spec_attempted: spec,
1085
+ };
1086
+ }
1087
+ }
1088
+ const compiled = compileWorkflow(spec, { registry: actionRegistry });
1089
+ // Validate the generated workflow (structural checks)
768
1090
  const issues = detectWorkflowIssues(compiled.workflow_def);
769
1091
  const result = {
770
1092
  mode: "generate",
@@ -773,6 +1095,14 @@ export async function handleWorkflow(args, client, getTemplateId) {
773
1095
  proto_config: compiled.proto_config,
774
1096
  validation: parseResult.validation,
775
1097
  };
1098
+ // Include API validation results if available
1099
+ if (specValidation) {
1100
+ result.api_validation = {
1101
+ valid: specValidation.valid,
1102
+ warnings: specValidation.warnings,
1103
+ action_coverage: specValidation.action_coverage,
1104
+ };
1105
+ }
776
1106
  if (issues.length > 0) {
777
1107
  result.issues = issues;
778
1108
  }
@@ -790,18 +1120,19 @@ export async function handleWorkflow(args, client, getTemplateId) {
790
1120
  result.deployed_to = { persona_id: personaId, persona_name: persona.name };
791
1121
  }
792
1122
  else if (!preview && !personaId) {
793
- // GREENFIELD: Create new persona from template, then update proto_config only
794
- // The template provides a valid workflow structure - we don't replace it,
795
- // we just configure the persona settings (voice config, welcome message, etc.)
1123
+ // GREENFIELD: Create new persona from template, then deploy workflow + proto_config
796
1124
  const personaName = args.name;
797
1125
  if (personaName) {
798
1126
  const personaType = args.type || "chat";
799
- const DEFAULT_TEMPLATES = {
800
- voice: "00000000-0000-0000-0000-00000000001e", // Voice AI template
801
- chat: "00000000-0000-0000-0000-000000000004", // Chat AI template
802
- dashboard: "00000000-0000-0000-0000-000000000002", // Dashboard AI template
1127
+ // Try API-driven template lookup first, fallback to hardcoded IDs
1128
+ const FALLBACK_TEMPLATES = {
1129
+ voice: "00000000-0000-0000-0000-00000000001e", // Voice AI template (fallback)
1130
+ chat: "00000000-0000-0000-0000-000000000004", // Chat AI template (fallback)
1131
+ dashboard: "00000000-0000-0000-0000-000000000002", // Dashboard AI template (fallback)
803
1132
  };
804
- const templateId = getTemplateId?.(personaType) || DEFAULT_TEMPLATES[personaType];
1133
+ // Use registry to find template by type
1134
+ const templateFromRegistry = actionRegistry.getTemplateForType(personaType);
1135
+ const templateId = getTemplateId?.(personaType) || templateFromRegistry?.id || FALLBACK_TEMPLATES[personaType];
805
1136
  if (!templateId) {
806
1137
  return {
807
1138
  error: `No template found for type "${personaType}". Provide template_id or use type: voice, chat, or dashboard.`
@@ -859,12 +1190,57 @@ export async function handleWorkflow(args, client, getTemplateId) {
859
1190
  ...existingProtoConfig,
860
1191
  widgets: Array.from(widgetMap.values()),
861
1192
  };
862
- // Step 4: Update persona with merged proto_config only
863
- // DO NOT send workflow - keep the template's valid workflow structure
864
- await client.updateAiEmployee({
865
- persona_id: newPersonaId,
866
- proto_config: mergedProtoConfig,
867
- });
1193
+ // Step 4: Deploy BOTH workflow AND proto_config for ALL persona types
1194
+ // Voice AI IS driven by workflow (not just identityAndPurpose)
1195
+ const existingWorkflow = newPersona.workflow_def;
1196
+ const existingWfName = existingWorkflow?.workflowName;
1197
+ // Clone our compiled workflow and set the namespace from the existing workflow
1198
+ const workflowForDeploy = JSON.parse(JSON.stringify(compiled.workflow_def));
1199
+ const wfName = workflowForDeploy.workflowName;
1200
+ if (wfName?.name && existingWfName?.name) {
1201
+ // Copy the exact namespace from the template workflow
1202
+ wfName.name.namespaces = existingWfName.name.namespaces;
1203
+ wfName.name.name = existingWfName.name.name; // template_id
1204
+ }
1205
+ // Fix results format - use "<actionName>.<outputName>" keys
1206
+ const compiledResults = workflowForDeploy.results;
1207
+ if (compiledResults) {
1208
+ const newResults = {};
1209
+ for (const [, value] of Object.entries(compiledResults)) {
1210
+ if (value.actionName && value.outputName) {
1211
+ const key = `${value.actionName}.${value.outputName}`;
1212
+ newResults[key] = value;
1213
+ }
1214
+ }
1215
+ workflowForDeploy.results = newResults;
1216
+ }
1217
+ try {
1218
+ await client.updateAiEmployee({
1219
+ persona_id: newPersonaId,
1220
+ workflow: workflowForDeploy,
1221
+ proto_config: mergedProtoConfig,
1222
+ }, { verbose: true });
1223
+ }
1224
+ catch (deployError) {
1225
+ // Workflow deploy failed - still set proto_config
1226
+ const errMsg = deployError instanceof Error ? deployError.message : String(deployError);
1227
+ await client.updateAiEmployee({
1228
+ persona_id: newPersonaId,
1229
+ proto_config: mergedProtoConfig,
1230
+ });
1231
+ result.workflow_deploy_error = errMsg;
1232
+ result.workflow_attempted = workflowForDeploy;
1233
+ result.status = "partial";
1234
+ result.hint = "Persona created, config set, but workflow deploy failed. Check workflow_attempted for details.";
1235
+ result.deployed_to = {
1236
+ persona_id: newPersonaId,
1237
+ persona_name: personaName,
1238
+ created: true,
1239
+ workflow_deployed: false,
1240
+ };
1241
+ return result;
1242
+ }
1243
+ // Success - workflow deployed for all persona types
868
1244
  result.deployed_to = {
869
1245
  persona_id: newPersonaId,
870
1246
  persona_name: personaName,
@@ -904,6 +1280,88 @@ export async function handleWorkflow(args, client, getTemplateId) {
904
1280
  if (!persona) {
905
1281
  return { error: `Persona not found: ${personaId}` };
906
1282
  }
1283
+ // ─────────────────────────────────────────────────────────────────────────
1284
+ // CONFIG-ONLY DETECTION: Route to proto_config update instead of workflow
1285
+ // ─────────────────────────────────────────────────────────────────────────
1286
+ const lowerInput = input.toLowerCase();
1287
+ const isConfigOnlyUpdate = ((lowerInput.includes("voice settings") || lowerInput.includes("voice config")) ||
1288
+ (lowerInput.includes("welcome message") && !lowerInput.includes("add") && !lowerInput.includes("node")) ||
1289
+ (lowerInput.includes("identity") && lowerInput.includes("purpose")) ||
1290
+ (lowerInput.includes("speech characteristics")) ||
1291
+ (lowerInput.includes("hangup instructions") || lowerInput.includes("hangup")) ||
1292
+ (lowerInput.includes("update") && (lowerInput.includes("persona settings") ||
1293
+ lowerInput.includes("conversation settings") ||
1294
+ lowerInput.includes("proto_config") ||
1295
+ lowerInput.includes("chat settings"))));
1296
+ if (isConfigOnlyUpdate) {
1297
+ // This is a config update, not a workflow modification
1298
+ // Parse the config values from the input and update proto_config only
1299
+ const existingProtoConfig = (persona.proto_config ?? {});
1300
+ const existingWidgets = (existingProtoConfig.widgets ?? []);
1301
+ // Find and update the conversationSettings widget
1302
+ const widgetMap = new Map();
1303
+ for (const w of existingWidgets) {
1304
+ if (typeof w.name === "string" && w.name.trim().length > 0) {
1305
+ widgetMap.set(w.name, { ...w });
1306
+ }
1307
+ }
1308
+ // Parse voice config updates from input
1309
+ const configUpdates = {};
1310
+ // Extract welcome message
1311
+ const welcomeMatch = input.match(/welcome\s*message[:\s]*["']?([^"'\n]+(?:\n(?![0-9]+\.).*)*?)(?:["']|(?=\n\n|\n[0-9]+\.))/i);
1312
+ if (welcomeMatch) {
1313
+ configUpdates.welcomeMessage = welcomeMatch[1].trim().replace(/^["']|["']$/g, '');
1314
+ }
1315
+ // Extract identity and purpose
1316
+ const identityMatch = input.match(/identity\s*(?:and\s*)?purpose[:\s]*["']?(.+?)(?:["']|(?=\n\n[0-9]+\.|$))/is);
1317
+ if (identityMatch) {
1318
+ configUpdates.identityAndPurpose = identityMatch[1].trim().replace(/^["']|["']$/g, '');
1319
+ }
1320
+ // Extract speech characteristics
1321
+ const speechMatch = input.match(/speech\s*characteristics?[:\s]*["']?([^"'\n]+)["']?/i);
1322
+ if (speechMatch) {
1323
+ configUpdates.speechCharacteristics = speechMatch[1].trim().replace(/^["']|["']$/g, '');
1324
+ }
1325
+ // Extract hangup instructions
1326
+ const hangupMatch = input.match(/hangup\s*instructions?[:\s]*["']?(.+?)(?:["']|$)/is);
1327
+ if (hangupMatch) {
1328
+ configUpdates.hangupInstructions = hangupMatch[1].trim().replace(/^["']|["']$/g, '');
1329
+ }
1330
+ // Update conversationSettings widget
1331
+ const convWidget = widgetMap.get("conversationSettings") || {
1332
+ name: "conversationSettings",
1333
+ type: 39,
1334
+ conversationSettings: {},
1335
+ };
1336
+ const convSettings = (convWidget.conversationSettings ?? {});
1337
+ // Merge config updates
1338
+ for (const [key, value] of Object.entries(configUpdates)) {
1339
+ convSettings[key] = value;
1340
+ }
1341
+ convWidget.conversationSettings = convSettings;
1342
+ widgetMap.set("conversationSettings", convWidget);
1343
+ const mergedProtoConfig = {
1344
+ ...existingProtoConfig,
1345
+ widgets: Array.from(widgetMap.values()),
1346
+ };
1347
+ if (!preview) {
1348
+ // Deploy config update only - preserve existing workflow
1349
+ await client.updateAiEmployee({
1350
+ persona_id: personaId,
1351
+ proto_config: mergedProtoConfig,
1352
+ workflow: persona.workflow_def,
1353
+ });
1354
+ }
1355
+ return {
1356
+ mode: "config_update",
1357
+ status: preview ? "preview" : "deployed",
1358
+ persona_id: personaId,
1359
+ persona_name: persona.name,
1360
+ config_updates: configUpdates,
1361
+ note: "Updated persona configuration only. Workflow unchanged.",
1362
+ hint: "To modify the workflow itself, describe workflow changes like 'add search node' or 'add HITL before email'.",
1363
+ };
1364
+ }
907
1365
  const existingWorkflow = persona.workflow_def;
908
1366
  if (!existingWorkflow) {
909
1367
  return {
@@ -1139,6 +1597,8 @@ export async function handleWorkflow(args, client, getTemplateId) {
1139
1597
  if (!nodes) {
1140
1598
  return { error: "nodes required for compile mode" };
1141
1599
  }
1600
+ // Load action registry for API-driven action versions/namespaces
1601
+ const compileRegistry = await ensureActionRegistry(client);
1142
1602
  const spec = {
1143
1603
  name: args.name || "Compiled Workflow",
1144
1604
  description: args.description || "Generated workflow",
@@ -1146,10 +1606,11 @@ export async function handleWorkflow(args, client, getTemplateId) {
1146
1606
  nodes: nodes,
1147
1607
  result_mappings: resultMappings || [],
1148
1608
  };
1149
- const compiled = compileWorkflow(spec);
1609
+ const compiled = compileWorkflow(spec, { registry: compileRegistry });
1150
1610
  return {
1151
1611
  workflow_def: compiled.workflow_def,
1152
1612
  proto_config: compiled.proto_config,
1613
+ registry_loaded: compileRegistry.isLoaded(),
1153
1614
  };
1154
1615
  }
1155
1616
  case "optimize": {
@@ -1541,6 +2002,205 @@ export async function handleKnowledge(args, client, readFile) {
1541
2002
  message: `Data source '${widgetName}' attached to node '${nodeName}'`,
1542
2003
  };
1543
2004
  }
2005
+ case "dashboard_rows": {
2006
+ // Get rows from a dashboard persona
2007
+ const persona = await client.getPersonaById(personaId);
2008
+ if (!persona) {
2009
+ return { error: `Persona not found: ${personaId}` };
2010
+ }
2011
+ const dashboardId = persona.workflow_dashboard_id;
2012
+ if (!dashboardId) {
2013
+ return {
2014
+ error: "This persona has no dashboard. Use dashboard_rows only for Dashboard-type AI Employees.",
2015
+ hint: "Check that the persona has trigger_type=DASHBOARD",
2016
+ };
2017
+ }
2018
+ const limit = args.limit ?? 100;
2019
+ const result = await client.getDashboardRows(dashboardId, personaId, { limit });
2020
+ // Return schema and rows in a structured format
2021
+ return {
2022
+ persona_id: personaId,
2023
+ dashboard_id: dashboardId,
2024
+ dashboard_name: result.dashboardName,
2025
+ total_rows: result.totalCount,
2026
+ schema: {
2027
+ input_columns: result.schema.columns.filter(c => c.isInput),
2028
+ output_columns: result.schema.columns.filter(c => !c.isInput),
2029
+ },
2030
+ rows: result.rows.map(row => ({
2031
+ id: row.id,
2032
+ state: row.state,
2033
+ created_at: row.dashboardRowMetadata.createdAt,
2034
+ input_values: row.columnValues
2035
+ .filter(cv => result.schema.columns.find(c => c.columnId === cv.columnId)?.isInput)
2036
+ .map(cv => ({
2037
+ column: result.schema.columns.find(c => c.columnId === cv.columnId)?.name ?? cv.columnId,
2038
+ value: cv.value.stringValue ?? cv.value.documentCellValue?.documentValues?.[0]?.name ?? cv.value.arrayValue?.arrayValues?.[0]?.stringValue ?? "(complex)",
2039
+ })),
2040
+ })),
2041
+ };
2042
+ }
2043
+ case "dashboard_clone": {
2044
+ const sourcePersonaId = args.source_persona_id;
2045
+ if (!sourcePersonaId) {
2046
+ return { error: "source_persona_id required for dashboard_clone" };
2047
+ }
2048
+ // Get source and target personas
2049
+ const [sourcePersona, targetPersona] = await Promise.all([
2050
+ client.getPersonaById(sourcePersonaId),
2051
+ client.getPersonaById(personaId),
2052
+ ]);
2053
+ if (!sourcePersona) {
2054
+ return { error: `Source persona not found: ${sourcePersonaId}` };
2055
+ }
2056
+ if (!targetPersona) {
2057
+ return { error: `Target persona not found: ${personaId}` };
2058
+ }
2059
+ const sourceDashboardId = sourcePersona.workflow_dashboard_id;
2060
+ if (!sourceDashboardId) {
2061
+ return { error: "Source persona has no dashboard" };
2062
+ }
2063
+ // Dashboard operations require the persona to be enabled
2064
+ // Auto-enable the target persona for cloning
2065
+ const targetProtoConfig = targetPersona.proto_config;
2066
+ await client.updateAiEmployee({
2067
+ persona_id: personaId,
2068
+ proto_config: targetProtoConfig ?? {},
2069
+ enabled_by_user: true,
2070
+ });
2071
+ // Get source dashboard data
2072
+ const sourceRows = await client.getDashboardRows(sourceDashboardId, sourcePersonaId);
2073
+ if (sourceRows.rows.length === 0) {
2074
+ return {
2075
+ success: true,
2076
+ message: "No rows to clone - source dashboard is empty",
2077
+ source_rows: 0,
2078
+ cloned_rows: 0,
2079
+ };
2080
+ }
2081
+ // Identify input columns
2082
+ const inputColumns = sourceRows.schema.columns.filter(c => c.isInput);
2083
+ // Clone each row
2084
+ const results = [];
2085
+ const sanitize = args.sanitize;
2086
+ const sanitizeExamples = args.sanitize_examples;
2087
+ // Create sanitization session if needed
2088
+ let sanitizationSession;
2089
+ if (sanitize) {
2090
+ sanitizationSession = new SanitizationSession();
2091
+ }
2092
+ for (const row of sourceRows.rows) {
2093
+ try {
2094
+ // Build inputs from row's input column values
2095
+ const inputs = [];
2096
+ for (const inputCol of inputColumns) {
2097
+ const colValue = row.columnValues.find(cv => cv.columnId === inputCol.columnId);
2098
+ if (!colValue)
2099
+ continue;
2100
+ // Handle different column types
2101
+ if (inputCol.columnType === "COLUMN_TYPE_DOCUMENT") {
2102
+ // Document columns - we need to fetch the actual file content
2103
+ const docs = colValue.value.documentCellValue?.documentValues ?? [];
2104
+ if (docs.length > 0) {
2105
+ // For documents, we'd need to download from source and re-upload
2106
+ // For now, add as placeholder - requires file content fetching
2107
+ results.push({
2108
+ source_row_id: row.id,
2109
+ status: "skipped",
2110
+ error: "Document cloning requires file content transfer (not yet implemented)",
2111
+ });
2112
+ continue;
2113
+ }
2114
+ }
2115
+ else if (inputCol.columnType === "COLUMN_TYPE_STRING") {
2116
+ let value = colValue.value.stringValue ?? "";
2117
+ // Sanitize if enabled
2118
+ if (sanitizationSession && value) {
2119
+ // Simple pattern-based sanitization for known types
2120
+ const detected = detectWithPatterns(value);
2121
+ for (const entity of detected) {
2122
+ const replacement = sanitizationSession.getOrCreateReplacement(entity.value, entity.type);
2123
+ value = value.split(entity.value).join(replacement);
2124
+ }
2125
+ // Also apply any user-provided examples
2126
+ if (sanitizeExamples) {
2127
+ for (const example of sanitizeExamples) {
2128
+ if (value.includes(example)) {
2129
+ const replacement = sanitizationSession.getOrCreateReplacement(example, "unknown");
2130
+ value = value.split(example).join(replacement);
2131
+ }
2132
+ }
2133
+ }
2134
+ }
2135
+ inputs.push({
2136
+ name: inputCol.name,
2137
+ string_value: value,
2138
+ });
2139
+ }
2140
+ else if (inputCol.columnType === "COLUMN_TYPE_ARRAY") {
2141
+ // Array values - take first value as string for simplicity
2142
+ const arrayVals = colValue.value.arrayValue?.arrayValues ?? [];
2143
+ if (arrayVals.length > 0) {
2144
+ let value = arrayVals[0].stringValue ?? "";
2145
+ if (sanitizationSession && value) {
2146
+ const detected = detectWithPatterns(value);
2147
+ for (const entity of detected) {
2148
+ const replacement = sanitizationSession.getOrCreateReplacement(entity.value, entity.type);
2149
+ value = value.split(entity.value).join(replacement);
2150
+ }
2151
+ }
2152
+ inputs.push({
2153
+ name: inputCol.name,
2154
+ string_value: value,
2155
+ });
2156
+ }
2157
+ }
2158
+ }
2159
+ if (inputs.length === 0) {
2160
+ results.push({
2161
+ source_row_id: row.id,
2162
+ status: "skipped",
2163
+ error: "No clonable input values found",
2164
+ });
2165
+ continue;
2166
+ }
2167
+ // Upload the row to target dashboard
2168
+ const uploadResult = await client.uploadAndRunDashboardRow(personaId, inputs);
2169
+ results.push({
2170
+ source_row_id: row.id,
2171
+ target_row_id: uploadResult.row_id,
2172
+ status: "cloned",
2173
+ });
2174
+ }
2175
+ catch (err) {
2176
+ results.push({
2177
+ source_row_id: row.id,
2178
+ status: "error",
2179
+ error: err instanceof Error ? err.message : String(err),
2180
+ });
2181
+ }
2182
+ }
2183
+ const clonedCount = results.filter(r => r.status === "cloned").length;
2184
+ const skippedCount = results.filter(r => r.status === "skipped").length;
2185
+ const errorCount = results.filter(r => r.status === "error").length;
2186
+ return {
2187
+ success: true,
2188
+ source_persona_id: sourcePersonaId,
2189
+ target_persona_id: personaId,
2190
+ source_rows: sourceRows.rows.length,
2191
+ cloned_rows: clonedCount,
2192
+ skipped_rows: skippedCount,
2193
+ error_rows: errorCount,
2194
+ sanitization_applied: !!sanitize,
2195
+ details: results,
2196
+ notes: [
2197
+ "Dashboard clone creates NEW rows in the target dashboard",
2198
+ "Workflows will re-run on the new rows to generate output columns",
2199
+ "Document columns require manual re-upload (file content transfer not implemented)",
2200
+ sanitize ? "Sanitization applied to string/array values" : null,
2201
+ ].filter(Boolean),
2202
+ };
2203
+ }
1544
2204
  default:
1545
2205
  return { error: `Unknown mode: ${mode}` };
1546
2206
  }
@@ -2502,6 +3162,94 @@ function sanitizeWorkflowForDeploy(workflow) {
2502
3162
  // ═══════════════════════════════════════════════════════════════════════════
2503
3163
  // Helper Functions
2504
3164
  // ═══════════════════════════════════════════════════════════════════════════
3165
+ /**
3166
+ * Sanitize a persona's content (PII, company names, etc.)
3167
+ *
3168
+ * Uses two-pass approach:
3169
+ * 1. Detection pass: Find sensitive entities in all text fields
3170
+ * 2. Alignment pass: Apply ALL mappings for cross-field consistency
3171
+ *
3172
+ * Supports typed examples: strings (type inferred) or objects with explicit type.
3173
+ */
3174
+ async function sanitizePersonaById(client, personaId, options = {}) {
3175
+ try {
3176
+ // Fetch full persona
3177
+ const persona = await client.getPersonaById(personaId);
3178
+ if (!persona) {
3179
+ return { success: false, preview: true, replacements_applied: 0, items_needing_review: 0, error: "Persona not found" };
3180
+ }
3181
+ // Build persona object for sanitization - include ALL text-containing fields
3182
+ const personaData = {
3183
+ name: persona.name,
3184
+ description: persona.description,
3185
+ proto_config: persona.proto_config,
3186
+ workflow_def: persona.workflow_def,
3187
+ };
3188
+ // Create sanitization session with default policy
3189
+ const session = new SanitizationSession();
3190
+ // Normalize examples to TypedExample format
3191
+ const typedExamples = options.examples?.map(ex => {
3192
+ if (typeof ex === "string")
3193
+ return ex;
3194
+ return {
3195
+ value: ex.value,
3196
+ type: ex.type,
3197
+ replacement: ex.replacement,
3198
+ };
3199
+ });
3200
+ // Run sanitization with two-pass approach
3201
+ const sanitizationOptions = {
3202
+ examples: typedExamples,
3203
+ auto_replace_threshold: "high", // Only auto-replace high confidence
3204
+ };
3205
+ const result = sanitizePersona(personaData, session, sanitizationOptions);
3206
+ // If preview mode or there are items needing review, return preview
3207
+ const isPreview = options.preview !== false;
3208
+ if (isPreview || result.summary.items_needing_review > 0) {
3209
+ // Build confirmation prompt with all replacements and items needing review
3210
+ const allReplacements = result.results.flatMap(r => r.result.replacements);
3211
+ const allNeedsReview = result.results.flatMap(r => r.result.needs_review);
3212
+ const confirmation = buildConfirmationPrompt(allReplacements, allNeedsReview);
3213
+ return {
3214
+ success: true,
3215
+ preview: true,
3216
+ replacements_applied: 0,
3217
+ items_needing_review: allNeedsReview.length,
3218
+ by_class: result.summary.by_class,
3219
+ by_type: result.summary.by_type,
3220
+ confirmation,
3221
+ };
3222
+ }
3223
+ // Apply sanitization by updating the persona
3224
+ const sanitizedProtoConfig = result.sanitized.proto_config;
3225
+ const sanitizedWorkflow = result.sanitized.workflow_def;
3226
+ const sanitizedDescription = result.sanitized.description;
3227
+ // Update the persona with sanitized data
3228
+ await client.updateAiEmployee({
3229
+ persona_id: personaId,
3230
+ description: sanitizedDescription ?? persona.description,
3231
+ proto_config: (sanitizedProtoConfig ?? persona.proto_config ?? {}),
3232
+ workflow: sanitizedWorkflow ?? persona.workflow_def,
3233
+ });
3234
+ return {
3235
+ success: true,
3236
+ preview: false,
3237
+ by_class: result.summary.by_class,
3238
+ by_type: result.summary.by_type,
3239
+ replacements_applied: result.summary.total_replacements,
3240
+ items_needing_review: result.summary.items_needing_review,
3241
+ };
3242
+ }
3243
+ catch (error) {
3244
+ return {
3245
+ success: false,
3246
+ preview: true,
3247
+ replacements_applied: 0,
3248
+ items_needing_review: 0,
3249
+ error: error instanceof Error ? error.message : String(error),
3250
+ };
3251
+ }
3252
+ }
2505
3253
  async function resolvePersona(client, identifier) {
2506
3254
  // Try as UUID first
2507
3255
  const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;