@ema.co/mcp-toolkit 0.2.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +321 -0
  3. package/config.example.yaml +32 -0
  4. package/dist/cli/index.js +333 -0
  5. package/dist/config.js +136 -0
  6. package/dist/emaClient.js +398 -0
  7. package/dist/index.js +109 -0
  8. package/dist/mcp/handlers-consolidated.js +851 -0
  9. package/dist/mcp/index.js +15 -0
  10. package/dist/mcp/prompts.js +1753 -0
  11. package/dist/mcp/resources.js +624 -0
  12. package/dist/mcp/server.js +4723 -0
  13. package/dist/mcp/tools-consolidated.js +590 -0
  14. package/dist/mcp/tools-legacy.js +736 -0
  15. package/dist/models.js +8 -0
  16. package/dist/scheduler.js +21 -0
  17. package/dist/sdk/client.js +788 -0
  18. package/dist/sdk/config.js +136 -0
  19. package/dist/sdk/contracts.js +429 -0
  20. package/dist/sdk/generation-schema.js +189 -0
  21. package/dist/sdk/index.js +39 -0
  22. package/dist/sdk/knowledge.js +2780 -0
  23. package/dist/sdk/models.js +8 -0
  24. package/dist/sdk/state.js +88 -0
  25. package/dist/sdk/sync-options.js +216 -0
  26. package/dist/sdk/sync.js +220 -0
  27. package/dist/sdk/validation-rules.js +355 -0
  28. package/dist/sdk/workflow-generator.js +291 -0
  29. package/dist/sdk/workflow-intent.js +1585 -0
  30. package/dist/state.js +88 -0
  31. package/dist/sync.js +416 -0
  32. package/dist/syncOptions.js +216 -0
  33. package/dist/ui.js +334 -0
  34. package/docs/advisor-comms-assistant-fixes.md +175 -0
  35. package/docs/api-contracts.md +216 -0
  36. package/docs/auto-builder-analysis.md +271 -0
  37. package/docs/data-architecture.md +166 -0
  38. package/docs/ema-auto-builder-guide.html +394 -0
  39. package/docs/ema-user-guide.md +1121 -0
  40. package/docs/mcp-tools-guide.md +149 -0
  41. package/docs/naming-conventions.md +218 -0
  42. package/docs/tool-consolidation-proposal.md +427 -0
  43. package/package.json +98 -0
  44. package/resources/templates/chat-ai/README.md +119 -0
  45. package/resources/templates/chat-ai/persona-config.json +111 -0
  46. package/resources/templates/dashboard-ai/README.md +156 -0
  47. package/resources/templates/dashboard-ai/persona-config.json +180 -0
  48. package/resources/templates/voice-ai/README.md +123 -0
  49. package/resources/templates/voice-ai/persona-config.json +74 -0
  50. package/resources/templates/voice-ai/workflow-prompt.md +120 -0
@@ -0,0 +1,4723 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MCP (Model Context Protocol) Server for Ema API
4
+ *
5
+ * Provides tools for interacting with Ema AI Employees (personas), Actions (agents),
6
+ * and Persona Synchronization across multiple environments.
7
+ *
8
+ * Terminology mapping:
9
+ * - API: "persona" <-> UI: "AI Employee"
10
+ * - API: "action" <-> UI: "Agent"
11
+ *
12
+ * Environment Selection:
13
+ * - Tools accept an optional `env` parameter to target specific environments
14
+ * - Default environment is set via EMA_ENV_NAME or first in config
15
+ * - Available environments come from sync config (EMA_AGENT_SYNC_CONFIG)
16
+ */
17
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
18
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
20
+ // Prompts and Resources
21
+ import { PromptRegistry, isPromptError } from "./prompts.js";
22
+ import { ResourceRegistry, isResourceError } from "./resources.js";
23
+ import { EmaClient } from "../sdk/client.js";
24
+ import { loadConfigOptional } from "../sdk/config.js";
25
+ import { resolveSyncBehavior, loadSyncOptions } from "../sdk/sync-options.js";
26
+ import { SyncSDK } from "../sdk/sync.js";
27
+ import { fingerprintPersona, transformWorkflowForTarget, getCleanDescription, buildDescriptionWithSyncTag } from "../sync.js";
28
+ import { SYNC_METADATA_KEY } from "../sdk/models.js";
29
+ // Auto Builder Knowledge
30
+ import { AGENT_CATALOG, WORKFLOW_PATTERNS, QUALIFYING_QUESTIONS, PLATFORM_CONCEPTS, WORKFLOW_EXECUTION_MODEL, COMMON_MISTAKES, DEBUG_CHECKLIST, GUIDANCE_TOPICS, VOICE_PERSONA_TEMPLATE, PROJECT_TYPES, getAgentsByCategory, getAgentByName, getWidgetsForPersonaType, checkTypeCompatibility, getQualifyingQuestionsByCategory, getConceptByTerm, suggestAgentsForUseCase, validateWorkflowPrompt,
31
+ // Workflow Analysis
32
+ analyzeWorkflow, detectWorkflowIssues, validateWorkflowConnections, suggestWorkflowFixes, } from "../sdk/knowledge.js";
33
+ // Workflow Compiler (Template-driven)
34
+ import { compileWorkflow, } from "../sdk/workflow-generator.js";
35
+ // Workflow Intent (Normalization layer)
36
+ import { parseInput, intentToSpec, } from "../sdk/workflow-intent.js";
37
+ // Consolidated Tools (Unix CLI pattern) - NEW UNIFIED INTERFACE
38
+ import { generateConsolidatedTools, } from "./tools-consolidated.js";
39
+ // Legacy Tools (backwards compatibility) - DEPRECATED, OFF BY DEFAULT
40
+ import { generateLegacyTools, } from "./tools-legacy.js";
41
+ // Feature flag: Legacy tools are DISABLED by default to prevent LLM confusion
42
+ // Set EMA_ENABLE_LEGACY_TOOLS=true only if you need backwards compatibility
43
+ const ENABLE_LEGACY_TOOLS = process.env.EMA_ENABLE_LEGACY_TOOLS === "true";
44
+ import { handleEnv, handlePersona, handleAction, handleTemplate, handleKnowledge, handleReference, } from "./handlers-consolidated.js";
45
+ // ─────────────────────────────────────────────────────────────────────────────
46
+ // Autobuilder Prompt Generation
47
+ // ─────────────────────────────────────────────────────────────────────────────
48
+ /**
49
+ * Generate an optimized Autobuilder prompt from a natural language description.
50
+ * Uses knowledge of best practices, agent catalog, and workflow patterns.
51
+ */
52
+ function generateAutobuilderPrompt(description, personaType) {
53
+ const typeConfig = {
54
+ voice: {
55
+ trigger: "voice_trigger",
56
+ outputNote: "Use results mapping for Voice AI (not WORKFLOW_OUTPUT node)",
57
+ },
58
+ chat: {
59
+ trigger: "chat_trigger",
60
+ outputNote: "Connect final response to WORKFLOW_OUTPUT node",
61
+ },
62
+ dashboard: {
63
+ trigger: "document_trigger",
64
+ outputNote: "Process documents and connect to WORKFLOW_OUTPUT",
65
+ },
66
+ };
67
+ const config = typeConfig[personaType] ?? typeConfig.chat;
68
+ // Detect intents from description
69
+ const hasIntentRouting = /intent|route|categor|branch|if.*then/i.test(description);
70
+ const hasKBSearch = /search|knowledge|faq|document|lookup/i.test(description);
71
+ const hasExternalAction = /servicenow|salesforce|jira|ticket|create|update|api|external/i.test(description);
72
+ const hasHITL = /approv|human|review|escalat/i.test(description);
73
+ // Build structured prompt
74
+ let prompt = `Create a ${personaType} AI Employee workflow for:\n${description}\n\n`;
75
+ prompt += "Requirements:\n";
76
+ prompt += `- Trigger: ${config.trigger}\n`;
77
+ if (hasIntentRouting) {
78
+ prompt += "- Use chat_categorizer for intent routing with clear category definitions\n";
79
+ prompt += "- Include a Fallback category for unmatched queries\n";
80
+ prompt += "- Use runIf conditions to route based on categorizer output\n";
81
+ }
82
+ if (hasKBSearch) {
83
+ prompt += "- Use search_datastore for knowledge base queries\n";
84
+ prompt += "- Use conversation_to_search_query if multi-turn context needed\n";
85
+ prompt += "- Use respond_with_sources for answers with citations\n";
86
+ }
87
+ if (hasExternalAction) {
88
+ prompt += "- Use external_action_caller for external system integration\n";
89
+ if (!hasHITL) {
90
+ prompt += "- Add HITL approval before executing external actions (safety)\n";
91
+ }
92
+ }
93
+ if (hasHITL) {
94
+ prompt += "- Include general_hitl for human approval flows\n";
95
+ prompt += "- Handle both approval and rejection paths\n";
96
+ }
97
+ prompt += `- ${config.outputNote}\n`;
98
+ prompt += "- Ensure all paths lead to a response (no dead ends)\n";
99
+ prompt += "- Use correct input types: chat_conversation for categorizers, user_query for search\n";
100
+ return prompt;
101
+ }
102
+ /**
103
+ * Generate a human-readable summary of a workflow for Auto Builder prompts.
104
+ * This creates a description the Auto Builder can understand to recreate the workflow.
105
+ */
106
+ function summarizeWorkflow(workflowDef) {
107
+ const actions = workflowDef.actions;
108
+ if (!actions || actions.length === 0) {
109
+ return "Empty workflow with no actions";
110
+ }
111
+ const lines = [];
112
+ // Count action types
113
+ const actionTypes = new Map();
114
+ const nodeNames = [];
115
+ for (const action of actions) {
116
+ const actionType = action.actionType;
117
+ const typeName = actionType?.name?.name ?? "unknown";
118
+ actionTypes.set(typeName, (actionTypes.get(typeName) ?? 0) + 1);
119
+ const nodeName = String(action.name ?? "");
120
+ if (nodeName)
121
+ nodeNames.push(nodeName);
122
+ }
123
+ // Summary header
124
+ lines.push(`Workflow with ${actions.length} nodes:`);
125
+ // List action types and counts
126
+ for (const [type, count] of actionTypes.entries()) {
127
+ lines.push(`- ${type}: ${count}`);
128
+ }
129
+ // List node names
130
+ if (nodeNames.length > 0) {
131
+ lines.push(`\nNodes: ${nodeNames.slice(0, 20).join(", ")}${nodeNames.length > 20 ? "..." : ""}`);
132
+ }
133
+ // Check for categorizer (indicates intent routing)
134
+ const hasCategorizer = Array.from(actionTypes.keys()).some(t => t.includes("categorizer"));
135
+ if (hasCategorizer) {
136
+ lines.push("\nThis workflow uses intent-based routing with a categorizer.");
137
+ }
138
+ // Check for HITL
139
+ const hasHitl = Array.from(actionTypes.keys()).some(t => t.includes("hitl") || t.includes("general_hitl"));
140
+ if (hasHitl) {
141
+ lines.push("This workflow includes human-in-the-loop approval steps.");
142
+ }
143
+ // Include raw workflow JSON for Auto Builder to parse
144
+ lines.push("\n--- Full workflow_def JSON ---");
145
+ lines.push(JSON.stringify(workflowDef, null, 2));
146
+ return lines.join("\n");
147
+ }
148
+ /**
149
+ * Apply automatic fixes to a workflow based on detected issues.
150
+ * This function attempts to fix common issues like missing WORKFLOW_OUTPUT,
151
+ * wrong input sources, and other structural problems.
152
+ */
153
+ function applyWorkflowFixes(workflowDef, issues, persona) {
154
+ // Deep clone the workflow to avoid mutating the original
155
+ const fixedWorkflow = JSON.parse(JSON.stringify(workflowDef));
156
+ const appliedFixes = [];
157
+ const actions = fixedWorkflow.actions;
158
+ if (!actions) {
159
+ return { fixedWorkflow, appliedFixes: [{ issue_type: "unknown", description: "No actions array found in workflow", applied: false }] };
160
+ }
161
+ for (const issue of issues) {
162
+ let fixed = false;
163
+ let fixDescription = "";
164
+ let failReason = "";
165
+ switch (issue.type) {
166
+ case "missing_workflow_output": {
167
+ // For Voice AI, WORKFLOW_OUTPUT is handled via results mapping, not a node
168
+ // Check if results mapping exists
169
+ const results = fixedWorkflow.results;
170
+ if (results && Object.keys(results).length > 0) {
171
+ // Results mapping exists, this is valid for Voice AI
172
+ fixed = true;
173
+ fixDescription = "Workflow uses results mapping (valid for Voice AI) - no WORKFLOW_OUTPUT node needed";
174
+ }
175
+ else {
176
+ // Try to add results mapping for all response nodes
177
+ const responseNodes = actions.filter(a => {
178
+ const name = String(a.name ?? "");
179
+ return name.includes("respond") || name.includes("response");
180
+ });
181
+ if (responseNodes.length > 0) {
182
+ const newResults = {};
183
+ for (const respNode of responseNodes) {
184
+ const nodeName = String(respNode.name);
185
+ newResults[`${nodeName}.response_with_sources`] = {
186
+ actionName: nodeName,
187
+ outputName: "response_with_sources",
188
+ };
189
+ }
190
+ fixedWorkflow.results = newResults;
191
+ fixed = true;
192
+ fixDescription = `Added results mapping for ${responseNodes.length} response node(s)`;
193
+ }
194
+ else {
195
+ failReason = "No response nodes found to map to results";
196
+ }
197
+ }
198
+ break;
199
+ }
200
+ case "wrong_input_source": {
201
+ const targetNode = actions.find(a => String(a.name) === issue.node);
202
+ if (!targetNode || !targetNode.inputs) {
203
+ failReason = `Could not find node "${issue.node}" or its inputs`;
204
+ break;
205
+ }
206
+ const inputs = targetNode.inputs;
207
+ const nodeType = String(targetNode.actionType || targetNode.type || "");
208
+ // CASE 1: Email field needs entity extraction
209
+ // This requires adding a new node, not just rebinding
210
+ const isEmailField = issue.reason?.toLowerCase().includes("email") ||
211
+ issue.current?.toLowerCase().includes("email_to") ||
212
+ (nodeType.includes("send_email") && inputs.email_to);
213
+ if (isEmailField && inputs.email_to) {
214
+ // Check if entity_extraction already exists
215
+ let extractorNode = actions.find(a => String(a.actionType || a.type || "").includes("entity_extraction") ||
216
+ String(a.name).includes("entity_extract"));
217
+ if (!extractorNode) {
218
+ // Create entity_extraction node
219
+ const extractorName = "entity_extractor_email";
220
+ extractorNode = {
221
+ name: extractorName,
222
+ actionType: "entity_extraction",
223
+ inputs: {
224
+ conversation: {
225
+ actionOutput: {
226
+ actionName: "trigger",
227
+ output: "chat_conversation",
228
+ },
229
+ },
230
+ entities_to_extract: {
231
+ literalValue: {
232
+ value: "email_address",
233
+ },
234
+ },
235
+ },
236
+ outputs: ["email_address", "extracted_entities"],
237
+ };
238
+ actions.push(extractorNode);
239
+ // Wire email_to to use extracted email
240
+ inputs.email_to = {
241
+ actionOutput: {
242
+ actionName: extractorName,
243
+ output: "email_address",
244
+ },
245
+ };
246
+ fixed = true;
247
+ fixDescription = `Added entity_extraction node "${extractorName}" and wired ${issue.node}.email_to to use extracted email_address`;
248
+ }
249
+ else {
250
+ // Extractor exists, just rewire
251
+ inputs.email_to = {
252
+ actionOutput: {
253
+ actionName: String(extractorNode.name),
254
+ output: "email_address",
255
+ },
256
+ };
257
+ fixed = true;
258
+ fixDescription = `Wired ${issue.node}.email_to to use ${extractorNode.name}.email_address`;
259
+ }
260
+ break;
261
+ }
262
+ // CASE 2: Simple rebinding (conversation/query sources)
263
+ let inputField;
264
+ const possibleFields = ["conversation", "query", "input", "text"];
265
+ for (const field of possibleFields) {
266
+ if (inputs[field]) {
267
+ inputField = field;
268
+ break;
269
+ }
270
+ }
271
+ if (inputField && inputs[inputField]) {
272
+ const inputBinding = inputs[inputField];
273
+ if (inputBinding.actionOutput) {
274
+ const actionOutput = inputBinding.actionOutput;
275
+ // Update to use recommended source
276
+ if (issue.recommended === "chat_conversation") {
277
+ actionOutput.actionName = "trigger";
278
+ actionOutput.output = "chat_conversation";
279
+ fixed = true;
280
+ }
281
+ else if (issue.recommended === "user_query") {
282
+ actionOutput.actionName = "trigger";
283
+ actionOutput.output = "user_query";
284
+ fixed = true;
285
+ }
286
+ else if (issue.recommended === "summarized_conversation") {
287
+ // Check if summarizer exists
288
+ const summarizer = actions.find(a => String(a.name).includes("summarizer") ||
289
+ String(a.name).includes("conversation_to_search"));
290
+ if (summarizer) {
291
+ actionOutput.actionName = String(summarizer.name);
292
+ actionOutput.output = "summarized_conversation";
293
+ fixed = true;
294
+ }
295
+ else {
296
+ failReason = "Recommended summarized_conversation but no summarizer node exists";
297
+ break;
298
+ }
299
+ }
300
+ if (fixed) {
301
+ fixDescription = `Changed ${issue.node}.${inputField} from ${issue.current} to ${issue.recommended}`;
302
+ }
303
+ }
304
+ }
305
+ else if (!inputField) {
306
+ failReason = `Could not find input field (tried: conversation, query, input, text) in node "${issue.node}"`;
307
+ }
308
+ if (!fixed && !failReason) {
309
+ failReason = `Could not determine how to fix input source for "${issue.node}"`;
310
+ }
311
+ break;
312
+ }
313
+ case "incomplete_hitl": {
314
+ // This requires adding new nodes which is complex
315
+ // Mark as not auto-fixable
316
+ failReason = "HITL paths require manual configuration of success/failure response nodes";
317
+ break;
318
+ }
319
+ case "missing_category_edge": {
320
+ // Check if using runIf pattern (valid alternative to edges)
321
+ const nodesWithRunIf = actions.filter(a => a.runIf !== undefined);
322
+ const categorizerName = issue.node;
323
+ const nodesUsingCategorizer = nodesWithRunIf.filter(a => {
324
+ const runIf = a.runIf;
325
+ if (!runIf?.lhs)
326
+ return false;
327
+ const lhs = runIf.lhs;
328
+ const actionOutput = lhs.actionOutput;
329
+ return actionOutput?.actionName === categorizerName;
330
+ });
331
+ if (nodesUsingCategorizer.length > 0) {
332
+ fixed = true;
333
+ fixDescription = `Categorizer "${categorizerName}" uses runIf pattern (${nodesUsingCategorizer.length} conditional nodes) - valid alternative to explicit edges`;
334
+ }
335
+ else {
336
+ failReason = "Categorizer needs manual configuration of category routing edges";
337
+ }
338
+ break;
339
+ }
340
+ case "malformed_runif": {
341
+ // Fix malformed runIf: "category_<Name>" vs "true" -> "category" vs "<Name>"
342
+ const targetNode = actions.find(a => String(a.name) === issue.node);
343
+ if (targetNode?.runIf) {
344
+ const runIf = targetNode.runIf;
345
+ const lhs = runIf.lhs;
346
+ const rhs = runIf.rhs;
347
+ if (lhs?.actionOutput && rhs?.inline) {
348
+ const actionOutput = lhs.actionOutput;
349
+ const inlineRhs = rhs.inline;
350
+ const output = String(actionOutput.output ?? "");
351
+ if (output.startsWith("category_")) {
352
+ // Extract category name and fix the format
353
+ const categoryName = output.replace(/^category_/, "");
354
+ actionOutput.output = "category";
355
+ inlineRhs.enumValue = categoryName;
356
+ fixed = true;
357
+ fixDescription = `Fixed runIf for "${issue.node}": output="category", enumValue="${categoryName}"`;
358
+ }
359
+ }
360
+ }
361
+ if (!fixed) {
362
+ failReason = `Could not find node "${issue.node}" or its runIf condition`;
363
+ }
364
+ break;
365
+ }
366
+ case "orphan": {
367
+ // Remove orphan nodes
368
+ const orphanIndex = actions.findIndex(a => String(a.name) === issue.node);
369
+ if (orphanIndex >= 0) {
370
+ actions.splice(orphanIndex, 1);
371
+ fixed = true;
372
+ fixDescription = `Removed orphan node "${issue.node}" (not connected to workflow)`;
373
+ }
374
+ else {
375
+ failReason = `Could not find orphan node "${issue.node}" to remove`;
376
+ }
377
+ break;
378
+ }
379
+ case "redundant_search": {
380
+ // This is an optimization, not a critical fix - skip auto-fix
381
+ failReason = "Redundant search is an optimization suggestion - consider consolidating manually";
382
+ break;
383
+ }
384
+ case "type_mismatch": {
385
+ // Type mismatch fix strategy:
386
+ // 1. Handle multiBinding inputs - find and fix bindings inside multiBinding
387
+ // 2. Handle nested named_inputs targets - find binding inside named_inputs.multiBinding
388
+ // 3. For CHAT_CONVERSATION expected: use trigger.chat_conversation, move old to named_inputs
389
+ // 4. For SEARCH_RESULT expected: find actual search node output
390
+ // 5. For DOCUMENT/TEXT_WITH_SOURCES mismatches: move to named_inputs (accepts ANY type)
391
+ const sourceNode = issue.source?.split(".")[0];
392
+ const sourceOutput = issue.source?.split(".")[1];
393
+ const targetNode = issue.target?.split(".")[0];
394
+ const targetInput = issue.target?.split(".")[1];
395
+ const expectedType = issue.expected;
396
+ const gotType = issue.got;
397
+ if (!sourceNode || !targetNode || !targetInput) {
398
+ failReason = "Missing source/target node information";
399
+ break;
400
+ }
401
+ const targetAction = actions.find(a => String(a.name) === targetNode);
402
+ if (!targetAction || !targetAction.inputs) {
403
+ failReason = `Could not find target node "${targetNode}"`;
404
+ break;
405
+ }
406
+ const inputs = targetAction.inputs;
407
+ let inputBinding = inputs[targetInput];
408
+ let actionOutput;
409
+ let isNestedInNamedInputs = false;
410
+ let nestedBindingIndex = -1;
411
+ // Check for autoDetectedBinding - some bindings use this structure alongside or instead of others
412
+ if (inputBinding && !inputBinding.actionOutput && inputBinding.autoDetectedBinding) {
413
+ const autoBinding = inputBinding.autoDetectedBinding;
414
+ if (autoBinding.actionOutput) {
415
+ actionOutput = autoBinding.actionOutput;
416
+ }
417
+ }
418
+ // Check if targetInput is a named_inputs reference (e.g., "named_inputs_conversation")
419
+ if (!inputBinding && targetInput.startsWith("named_inputs_")) {
420
+ const namedInputKey = targetInput.replace("named_inputs_", "");
421
+ const namedInputs = inputs.named_inputs;
422
+ if (namedInputs?.multiBinding) {
423
+ const mb = namedInputs.multiBinding;
424
+ if (mb.elements) {
425
+ nestedBindingIndex = mb.elements.findIndex(el => el.namedBinding?.name?.toLowerCase() === namedInputKey.toLowerCase());
426
+ if (nestedBindingIndex >= 0) {
427
+ const namedBinding = mb.elements[nestedBindingIndex].namedBinding;
428
+ if (namedBinding?.value?.actionOutput) {
429
+ actionOutput = namedBinding.value.actionOutput;
430
+ isNestedInNamedInputs = true;
431
+ inputBinding = namedBinding.value;
432
+ }
433
+ }
434
+ }
435
+ }
436
+ // If we couldn't find the binding in multiBinding, the type mismatch for named_inputs
437
+ // is actually acceptable since named_inputs accepts ANY type
438
+ if (!actionOutput && namedInputs) {
439
+ fixed = true;
440
+ fixDescription = `${targetNode}.${targetInput} is in named_inputs (accepts any type) - type mismatch is acceptable`;
441
+ break;
442
+ }
443
+ }
444
+ // If direct binding exists, check for actionOutput or multiBinding
445
+ if (!actionOutput && inputBinding) {
446
+ if (inputBinding.actionOutput) {
447
+ actionOutput = inputBinding.actionOutput;
448
+ }
449
+ else if (inputBinding.multiBinding) {
450
+ // Handle multiBinding - find the element that references the source node
451
+ const mb = inputBinding.multiBinding;
452
+ if (mb.elements) {
453
+ for (let i = 0; i < mb.elements.length; i++) {
454
+ const el = mb.elements[i];
455
+ const elActionOutput = el.namedBinding?.value?.actionOutput;
456
+ if (elActionOutput && String(elActionOutput.actionName) === sourceNode) {
457
+ actionOutput = elActionOutput;
458
+ nestedBindingIndex = i;
459
+ break;
460
+ }
461
+ }
462
+ }
463
+ // Also check if elements have direct actionOutput (not inside namedBinding)
464
+ if (!actionOutput && mb.elements) {
465
+ for (let i = 0; i < mb.elements.length; i++) {
466
+ const el = mb.elements[i];
467
+ // Check for actionOutput directly on element
468
+ if (el.actionOutput) {
469
+ actionOutput = el.actionOutput;
470
+ nestedBindingIndex = i;
471
+ break;
472
+ }
473
+ }
474
+ }
475
+ // If source not found in multiBinding, DON'T mark as fixed - the type mismatch is real
476
+ // We need to find and remove/fix the mismatched binding
477
+ if (!actionOutput && mb.elements) {
478
+ // Find ANY binding that has type mismatch and try to fix it
479
+ for (let i = 0; i < mb.elements.length; i++) {
480
+ const el = mb.elements[i];
481
+ const elActionOutput = el.namedBinding?.value?.actionOutput;
482
+ if (elActionOutput) {
483
+ // Use the first actionOutput we find - it's likely the mismatched one
484
+ actionOutput = elActionOutput;
485
+ nestedBindingIndex = i;
486
+ break;
487
+ }
488
+ }
489
+ }
490
+ }
491
+ }
492
+ if (!actionOutput) {
493
+ failReason = `No actionOutput binding found for ${targetNode}.${targetInput}`;
494
+ break;
495
+ }
496
+ // === FIX STRATEGY 1: CHAT_CONVERSATION expected ===
497
+ // If binding is nested in named_inputs, redirect to trigger.chat_conversation
498
+ if (expectedType?.includes("CHAT_CONVERSATION")) {
499
+ const oldActionName = String(actionOutput.actionName ?? "");
500
+ const oldOutput = String(actionOutput.output ?? "");
501
+ if (isNestedInNamedInputs) {
502
+ // Binding is inside named_inputs - change it to use trigger.chat_conversation
503
+ actionOutput.actionName = "trigger";
504
+ actionOutput.output = "chat_conversation";
505
+ // Rename the named binding to indicate it's now conversation
506
+ const namedInputs = inputs.named_inputs;
507
+ const mb = namedInputs.multiBinding;
508
+ if (mb.elements && nestedBindingIndex >= 0 && mb.elements[nestedBindingIndex]?.namedBinding) {
509
+ // Keep the old binding as additional context
510
+ mb.elements.push({
511
+ namedBinding: {
512
+ name: `${targetInput.replace("named_inputs_", "")}_context`,
513
+ value: { actionOutput: { actionName: oldActionName, output: oldOutput } }
514
+ }
515
+ });
516
+ }
517
+ fixed = true;
518
+ fixDescription = `Fixed ${targetNode}.${targetInput}: now uses trigger.chat_conversation`;
519
+ break;
520
+ }
521
+ // Standard case: direct binding - change to trigger.chat_conversation
522
+ actionOutput.actionName = "trigger";
523
+ actionOutput.output = "chat_conversation";
524
+ // Add old binding to named_inputs so it's still available
525
+ const namedInputs = inputs.named_inputs;
526
+ const contextName = targetInput === "conversation" ? "context" : `${targetInput}_context`;
527
+ if (namedInputs?.multiBinding) {
528
+ // Append to existing multiBinding
529
+ const mb = namedInputs.multiBinding;
530
+ if (!mb.elements)
531
+ mb.elements = [];
532
+ mb.elements.push({
533
+ namedBinding: {
534
+ name: contextName,
535
+ value: { actionOutput: { actionName: oldActionName, output: oldOutput } }
536
+ }
537
+ });
538
+ }
539
+ else {
540
+ // Create new named_inputs with multiBinding
541
+ inputs.named_inputs = {
542
+ multiBinding: {
543
+ elements: [{
544
+ namedBinding: {
545
+ name: contextName,
546
+ value: { actionOutput: { actionName: oldActionName, output: oldOutput } }
547
+ }
548
+ }]
549
+ }
550
+ };
551
+ }
552
+ fixed = true;
553
+ fixDescription = `Fixed ${targetNode}.${targetInput}: now uses trigger.chat_conversation, old binding moved to named_inputs.${contextName}`;
554
+ break;
555
+ }
556
+ // === FIX STRATEGY 2: SEARCH_RESULT expected ===
557
+ // Find an actual search node and use its search_results output
558
+ if (expectedType?.includes("SEARCH_RESULT") && gotType?.includes("TEXT_WITH_SOURCES")) {
559
+ // Find a search node (excluding web_search which has different output)
560
+ const searchNode = actions.find(a => {
561
+ const name = String(a.name ?? "");
562
+ const actionType = a.actionType?.name?.name ?? "";
563
+ return (name.includes("search") || actionType.includes("search_datastore"))
564
+ && !name.includes("web_search")
565
+ && !name.includes("combine");
566
+ });
567
+ if (searchNode) {
568
+ // Change to use search node's output directly
569
+ // Don't add old binding to named_inputs - it causes type mismatches
570
+ actionOutput.actionName = String(searchNode.name);
571
+ actionOutput.output = "search_results";
572
+ fixed = true;
573
+ fixDescription = `Fixed ${targetNode}.${targetInput}: now uses ${searchNode.name}.search_results`;
574
+ }
575
+ else {
576
+ failReason = `No search_datastore node found to provide SEARCH_RESULT for ${targetNode}`;
577
+ }
578
+ break;
579
+ }
580
+ // === FIX STRATEGY 3: DOCUMENT or other type → remove the mismatched binding ===
581
+ // Simply remove the bad binding - don't move to named_inputs (causes type mismatches)
582
+ if (gotType?.includes("DOCUMENT") ||
583
+ (expectedType?.includes("TEXT_WITH_SOURCES") && !gotType?.includes("TEXT_WITH_SOURCES"))) {
584
+ // If binding is inside multiBinding of the target input, remove the bad element
585
+ if (inputBinding?.multiBinding && nestedBindingIndex >= 0) {
586
+ const mb = inputBinding.multiBinding;
587
+ if (mb.elements && mb.elements.length > 1) {
588
+ // Remove the mismatched element, keep others
589
+ mb.elements.splice(nestedBindingIndex, 1);
590
+ fixed = true;
591
+ fixDescription = `Fixed ${targetNode}.${targetInput}: removed mismatched binding from multiBinding`;
592
+ break;
593
+ }
594
+ else if (mb.elements && mb.elements.length === 1) {
595
+ // Only one element - remove entire binding
596
+ delete inputBinding.multiBinding;
597
+ }
598
+ }
599
+ // Remove the mismatched binding from the typed input entirely
600
+ delete inputs[targetInput];
601
+ fixed = true;
602
+ fixDescription = `Fixed ${targetNode}.${targetInput}: removed mismatched binding (input now unbound)`;
603
+ break;
604
+ }
605
+ // === FALLBACK: Remove mismatched binding ===
606
+ // Don't move to named_inputs - it causes type mismatches
607
+ {
608
+ // Remove the mismatched binding entirely
609
+ delete inputs[targetInput];
610
+ fixed = true;
611
+ fixDescription = `Fixed ${targetNode}.${targetInput}: removed mismatched binding (${gotType} → expected ${expectedType})`;
612
+ }
613
+ break;
614
+ }
615
+ default: {
616
+ failReason = `Auto-fix not implemented for issue type: ${issue.type}`;
617
+ }
618
+ }
619
+ appliedFixes.push({
620
+ issue_type: issue.type,
621
+ description: fixed ? fixDescription : `Could not fix: ${issue.reason}`,
622
+ applied: fixed,
623
+ reason: fixed ? undefined : failReason,
624
+ });
625
+ }
626
+ return { fixedWorkflow, appliedFixes };
627
+ }
628
+ // Well-known Ema environments for auto-detection
629
+ const KNOWN_ENVS = {
630
+ prod: "https://api.ema.co", // Default for external users
631
+ demo: "https://api.demo.ema.co", // Default for Ema employees
632
+ dev: "https://api.dev.ema.co",
633
+ staging: "https://api.staging.ema.co",
634
+ };
635
+ // Cache for API key -> JWT token
636
+ const apiKeyTokenCache = new Map();
637
+ /**
638
+ * Generate JWT token from API key.
639
+ * API keys are more stable than bearer tokens (24h validity with auto-refresh).
640
+ */
641
+ async function generateTokenFromApiKey(baseUrl, apiKey) {
642
+ // Check cache first
643
+ const cacheKey = `${baseUrl}:${apiKey.slice(0, 8)}`;
644
+ const cached = apiKeyTokenCache.get(cacheKey);
645
+ if (cached && cached.expiresAt > Date.now() + 5 * 60 * 1000) { // 5 min buffer
646
+ return cached.token;
647
+ }
648
+ const url = `${baseUrl.replace(/\/$/, "")}/api/auth/generate_access_token`;
649
+ const response = await fetch(url, {
650
+ method: "POST",
651
+ headers: {
652
+ "x-ema-api-key": apiKey,
653
+ "Content-Type": "application/json",
654
+ },
655
+ });
656
+ if (!response.ok) {
657
+ if (response.status === 401) {
658
+ throw new Error("Invalid API key");
659
+ }
660
+ else if (response.status === 429) {
661
+ throw new Error("Too many token requests. Tokens are valid for 24h - reuse until expiry.");
662
+ }
663
+ throw new Error(`Failed to generate token: ${response.status}`);
664
+ }
665
+ const data = await response.json();
666
+ const token = data.access_token;
667
+ // Cache for ~23 hours (tokens valid 24h, refresh at 23h)
668
+ apiKeyTokenCache.set(cacheKey, {
669
+ token,
670
+ expiresAt: Date.now() + 23 * 60 * 60 * 1000,
671
+ });
672
+ return token;
673
+ }
674
+ /**
675
+ * Detect if this is an Ema employee based on:
676
+ * - EMA_EMPLOYEE env var set to "true"
677
+ * - Demo token configured (suggests internal user)
678
+ */
679
+ function isEmaEmployee() {
680
+ if (process.env.EMA_EMPLOYEE === "true")
681
+ return true;
682
+ if (process.env.EMA_DEMO_BEARER_TOKEN || process.env.EMA_DEMO_API_KEY)
683
+ return true;
684
+ return false;
685
+ }
686
+ /**
687
+ * Get the default environment name.
688
+ * - Ema employees: demo
689
+ * - External users: prod
690
+ */
691
+ function getDefaultEnvironmentName() {
692
+ // Explicit preference
693
+ if (process.env.EMA_ENV_NAME)
694
+ return process.env.EMA_ENV_NAME;
695
+ // Ema employees default to demo
696
+ if (isEmaEmployee())
697
+ return "demo";
698
+ // External users default to prod
699
+ return "prod";
700
+ }
701
+ let cachedConfig = null;
702
+ let cachedEnvInfos = null;
703
+ function getConfig() {
704
+ if (cachedConfig === undefined) {
705
+ const configPath = process.env.EMA_AGENT_SYNC_CONFIG ?? "./config.yaml";
706
+ cachedConfig = loadConfigOptional(configPath);
707
+ }
708
+ return cachedConfig;
709
+ }
710
+ /**
711
+ * Detect available environments from:
712
+ * 1. YAML config file (if present) - full sync support
713
+ * 2. Environment variables (fallback) - browse-only mode
714
+ * - Looks for EMA_<ENV>_BEARER_TOKEN patterns
715
+ * - Uses well-known URLs or EMA_<ENV>_BASE_URL
716
+ */
717
+ function getAvailableEnvironments() {
718
+ if (cachedEnvInfos)
719
+ return cachedEnvInfos;
720
+ // Option 1: From YAML config
721
+ const cfg = getConfig();
722
+ if (cfg?.environments?.length) {
723
+ cachedEnvInfos = cfg.environments.map((e) => ({
724
+ name: e.name,
725
+ baseUrl: e.baseUrl,
726
+ tokenEnvVar: e.bearerTokenEnv,
727
+ }));
728
+ return cachedEnvInfos;
729
+ }
730
+ // Option 2: Auto-detect from env vars
731
+ // Supports: EMA_<ENV>_BEARER_TOKEN or EMA_<ENV>_API_KEY
732
+ const detected = [];
733
+ const tokenPattern = /^EMA_([A-Z]+)_(BEARER_TOKEN|API_KEY)$/;
734
+ const seenEnvs = new Set();
735
+ for (const [key, value] of Object.entries(process.env)) {
736
+ if (!value)
737
+ continue;
738
+ const match = key.match(tokenPattern);
739
+ if (match) {
740
+ const envName = match[1].toLowerCase();
741
+ if (seenEnvs.has(envName))
742
+ continue; // Prefer first match (BEARER_TOKEN > API_KEY)
743
+ seenEnvs.add(envName);
744
+ const baseUrlKey = `EMA_${match[1]}_BASE_URL`;
745
+ const baseUrl = process.env[baseUrlKey] ?? KNOWN_ENVS[envName] ?? `https://api.${envName}.ema.co`;
746
+ detected.push({
747
+ name: envName,
748
+ baseUrl,
749
+ tokenEnvVar: key,
750
+ isApiKey: key.endsWith("_API_KEY"),
751
+ });
752
+ }
753
+ }
754
+ if (detected.length > 0) {
755
+ cachedEnvInfos = detected;
756
+ return cachedEnvInfos;
757
+ }
758
+ // Option 3: Single default environment (prod for external, demo for Ema employees)
759
+ const defaultEnv = getDefaultEnvironmentName();
760
+ const baseUrl = process.env.EMA_BASE_URL ?? KNOWN_ENVS[defaultEnv] ?? "https://api.ema.co";
761
+ cachedEnvInfos = [{
762
+ name: defaultEnv,
763
+ baseUrl,
764
+ tokenEnvVar: process.env.EMA_API_KEY ? "EMA_API_KEY" : "EMA_BEARER_TOKEN",
765
+ isApiKey: Boolean(process.env.EMA_API_KEY),
766
+ }];
767
+ return cachedEnvInfos;
768
+ }
769
+ function getDefaultEnvName() {
770
+ const envs = getAvailableEnvironments();
771
+ // Use the smart default (Ema employee vs external user)
772
+ const preferred = getDefaultEnvironmentName();
773
+ if (envs.some((e) => e.name === preferred)) {
774
+ return preferred;
775
+ }
776
+ return envs[0]?.name ?? "prod";
777
+ }
778
+ // Cache for resolved API key tokens (lazily populated)
779
+ const resolvedApiKeyTokens = new Map();
780
+ /**
781
+ * Initialize API key tokens at startup (call this early if using API keys).
782
+ * This is non-blocking - tokens are resolved lazily on first use.
783
+ */
784
+ async function initializeApiKeyTokens() {
785
+ const envs = getAvailableEnvironments();
786
+ for (const env of envs) {
787
+ if (env.isApiKey) {
788
+ const apiKey = process.env[env.tokenEnvVar];
789
+ if (apiKey) {
790
+ try {
791
+ const token = await generateTokenFromApiKey(env.baseUrl, apiKey);
792
+ resolvedApiKeyTokens.set(env.name, token);
793
+ }
794
+ catch (e) {
795
+ console.error(`Failed to initialize API key for ${env.name}:`, e);
796
+ }
797
+ }
798
+ }
799
+ }
800
+ }
801
+ // Start token initialization in background (non-blocking)
802
+ void initializeApiKeyTokens();
803
+ function createClient(envName) {
804
+ const targetEnv = envName ?? getDefaultEnvName();
805
+ const envs = getAvailableEnvironments();
806
+ const envInfo = envs.find((e) => e.name === targetEnv);
807
+ if (!envInfo) {
808
+ const available = envs.map((e) => e.name).join(", ");
809
+ throw new Error(`Unknown environment: ${targetEnv}. Available: ${available}`);
810
+ }
811
+ let bearerToken;
812
+ if (envInfo.isApiKey) {
813
+ // Check if we have a resolved token from API key
814
+ const resolved = resolvedApiKeyTokens.get(targetEnv);
815
+ if (resolved) {
816
+ bearerToken = resolved;
817
+ }
818
+ else {
819
+ // Token not yet resolved - try to use the API key directly (will fail, but gives helpful error)
820
+ throw new Error(`API key for ${targetEnv} not yet initialized. ` +
821
+ `Either wait for initialization or set EMA_${targetEnv.toUpperCase()}_BEARER_TOKEN instead of API_KEY.`);
822
+ }
823
+ }
824
+ else {
825
+ const token = process.env[envInfo.tokenEnvVar];
826
+ if (!token) {
827
+ throw new Error(`Missing token for environment ${targetEnv}. Set ${envInfo.tokenEnvVar}`);
828
+ }
829
+ bearerToken = token;
830
+ }
831
+ // Set up auto-refresh for API key auth
832
+ const tokenRefreshConfig = envInfo.isApiKey ? {
833
+ tokenRefresh: {
834
+ refreshCallback: async () => {
835
+ const apiKey = process.env[envInfo.tokenEnvVar];
836
+ if (!apiKey)
837
+ return null;
838
+ try {
839
+ const newToken = await generateTokenFromApiKey(envInfo.baseUrl, apiKey);
840
+ resolvedApiKeyTokens.set(targetEnv, newToken);
841
+ return newToken;
842
+ }
843
+ catch {
844
+ return null;
845
+ }
846
+ },
847
+ refreshIntervalMs: 23 * 60 * 60 * 1000, // 23 hours (tokens valid 24h)
848
+ backgroundRefresh: true,
849
+ },
850
+ } : undefined;
851
+ return new EmaClient({
852
+ name: envInfo.name,
853
+ baseUrl: envInfo.baseUrl,
854
+ bearerToken,
855
+ }, tokenRefreshConfig);
856
+ }
857
+ function getSyncSDK() {
858
+ const cfg = getConfig();
859
+ if (!cfg)
860
+ return null;
861
+ return new SyncSDK(cfg);
862
+ }
863
+ async function directSyncPersona(opts) {
864
+ const { name, sourceEnv, targetEnv, dryRun, syncStatus } = opts;
865
+ // Create clients
866
+ const sourceClient = createClient(sourceEnv);
867
+ const targetClient = createClient(targetEnv);
868
+ // Find source persona by name (list doesn't include workflow_def)
869
+ const sourcePersonas = await sourceClient.getPersonasForTenant();
870
+ const sourcePersonaFromList = sourcePersonas.find((p) => p.name === name);
871
+ if (!sourcePersonaFromList) {
872
+ throw new Error(`Persona not found in ${sourceEnv}: ${name}`);
873
+ }
874
+ // Fetch full persona to get workflow_def
875
+ const sourcePersona = await sourceClient.getPersonaById(sourcePersonaFromList.id);
876
+ if (!sourcePersona) {
877
+ throw new Error(`Failed to fetch full persona details: ${sourcePersonaFromList.id}`);
878
+ }
879
+ return directSyncPersonaImpl({ sourcePersona, sourceEnv, targetEnv, sourceClient, targetClient, dryRun, syncStatus });
880
+ }
881
+ async function directSyncPersonaById(opts) {
882
+ const { personaId, sourceEnv, targetEnv, dryRun, syncStatus } = opts;
883
+ // Create clients
884
+ const sourceClient = createClient(sourceEnv);
885
+ const targetClient = createClient(targetEnv);
886
+ // Get full source persona
887
+ const sourcePersona = await sourceClient.getPersonaById(personaId);
888
+ if (!sourcePersona) {
889
+ throw new Error(`Persona not found in ${sourceEnv}: ${personaId}`);
890
+ }
891
+ return directSyncPersonaImpl({ sourcePersona, sourceEnv, targetEnv, sourceClient, targetClient, dryRun, syncStatus });
892
+ }
893
+ async function directSyncPersonaImpl(opts) {
894
+ const { sourcePersona, sourceEnv, targetEnv, targetClient, dryRun, syncStatus } = opts;
895
+ // Compute fingerprint
896
+ const fp = fingerprintPersona(sourcePersona);
897
+ // Find existing replica in target by sync tag or name
898
+ let targetPersonaId;
899
+ let created = false;
900
+ // First, check by sync tag (most reliable)
901
+ const syncedResult = await targetClient.findSyncedPersona(sourceEnv, sourcePersona.id);
902
+ if (syncedResult) {
903
+ targetPersonaId = syncedResult.persona.id;
904
+ // Check if fingerprints match - skip if no changes
905
+ if (syncedResult.syncMetadata.master_fingerprint === fp) {
906
+ return {
907
+ success: true,
908
+ source_env: sourceEnv,
909
+ target_env: targetEnv,
910
+ source_persona: { id: sourcePersona.id, name: sourcePersona.name },
911
+ target_persona: { id: targetPersonaId, name: syncedResult.persona.name, created: false },
912
+ fingerprint: fp,
913
+ dry_run: dryRun,
914
+ error: "Already in sync (fingerprints match)",
915
+ };
916
+ }
917
+ }
918
+ // Fallback: find by name
919
+ if (!targetPersonaId && sourcePersona.name) {
920
+ const targetPersonas = await targetClient.getPersonasForTenant();
921
+ const byName = targetPersonas.find((p) => p.name === sourcePersona.name);
922
+ if (byName) {
923
+ targetPersonaId = byName.id;
924
+ }
925
+ }
926
+ if (dryRun) {
927
+ return {
928
+ success: true,
929
+ source_env: sourceEnv,
930
+ target_env: targetEnv,
931
+ source_persona: { id: sourcePersona.id, name: sourcePersona.name },
932
+ target_persona: targetPersonaId
933
+ ? { id: targetPersonaId, name: sourcePersona.name, created: false }
934
+ : undefined,
935
+ fingerprint: fp,
936
+ dry_run: true,
937
+ };
938
+ }
939
+ // Build sync metadata
940
+ const syncMetadata = {
941
+ master_env: sourceEnv,
942
+ master_id: sourcePersona.id,
943
+ synced_at: new Date().toISOString(),
944
+ master_fingerprint: fp,
945
+ };
946
+ // Copy proto_config from source (sync metadata now goes in status_log)
947
+ const protoConfig = { ...(sourcePersona.proto_config ?? {}) };
948
+ delete protoConfig[SYNC_METADATA_KEY]; // Clean any legacy sync tags from proto_config
949
+ if (targetPersonaId) {
950
+ // Update existing persona
951
+ // Transform workflow if present
952
+ let workflow;
953
+ if (sourcePersona.workflow_def) {
954
+ workflow = transformWorkflowForTarget(sourcePersona.workflow_def, sourcePersona.id, targetPersonaId);
955
+ }
956
+ // Clean source description and add compact sync tag
957
+ const cleanSourceDesc = getCleanDescription(sourcePersona.description);
958
+ const descriptionWithTag = buildDescriptionWithSyncTag(cleanSourceDesc, sourceEnv, sourcePersona.id);
959
+ // Clean proto_config description too
960
+ const protoConfigClean = {
961
+ ...protoConfig,
962
+ description: getCleanDescription(protoConfig.description),
963
+ };
964
+ // Update persona with sync tag in description
965
+ await targetClient.updateAiEmployee({
966
+ persona_id: targetPersonaId,
967
+ name: sourcePersona.name,
968
+ description: descriptionWithTag,
969
+ proto_config: protoConfigClean,
970
+ welcome_messages: sourcePersona.welcome_messages,
971
+ embedding_enabled: sourcePersona.embedding_enabled,
972
+ workflow,
973
+ // Optionally sync enabled/disabled status (status can be "ready", "active", "inactive", etc.)
974
+ ...(syncStatus && { enabled_by_user: sourcePersona.status !== "inactive" }),
975
+ });
976
+ }
977
+ else {
978
+ // Create new persona
979
+ const templateId = (sourcePersona.template_id ?? sourcePersona.templateId);
980
+ if (!templateId) {
981
+ throw new Error(`Cannot create persona in ${targetEnv}: source has no template_id`);
982
+ }
983
+ // Clean source description and add compact sync tag
984
+ const cleanSourceDescCreate = getCleanDescription(sourcePersona.description);
985
+ const descriptionWithTagCreate = buildDescriptionWithSyncTag(cleanSourceDescCreate, sourceEnv, sourcePersona.id);
986
+ // Clean proto_config description
987
+ const protoConfigCleanCreate = {
988
+ ...protoConfig,
989
+ description: getCleanDescription(protoConfig.description),
990
+ };
991
+ const createResult = await targetClient.createAiEmployee({
992
+ name: sourcePersona.name ?? "Unnamed Persona",
993
+ description: descriptionWithTagCreate,
994
+ template_id: templateId,
995
+ proto_config: protoConfigCleanCreate,
996
+ welcome_messages: sourcePersona.welcome_messages,
997
+ trigger_type: sourcePersona.trigger_type,
998
+ });
999
+ targetPersonaId = createResult.persona_id ?? createResult.id;
1000
+ created = true;
1001
+ // Follow-up: Now that we have target persona ID, sync the workflow
1002
+ if (sourcePersona.workflow_def && targetPersonaId) {
1003
+ const workflow = transformWorkflowForTarget(sourcePersona.workflow_def, sourcePersona.id, targetPersonaId);
1004
+ // Update with transformed workflow
1005
+ await targetClient.updateAiEmployee({
1006
+ persona_id: targetPersonaId,
1007
+ proto_config: protoConfigCleanCreate,
1008
+ workflow,
1009
+ });
1010
+ }
1011
+ }
1012
+ return {
1013
+ success: true,
1014
+ source_env: sourceEnv,
1015
+ target_env: targetEnv,
1016
+ source_persona: { id: sourcePersona.id, name: sourcePersona.name },
1017
+ target_persona: { id: targetPersonaId, name: sourcePersona.name, created },
1018
+ fingerprint: fp,
1019
+ };
1020
+ }
1021
+ /**
1022
+ * Config-less sync all: find all personas with sync tags in target env and re-sync from source
1023
+ */
1024
+ async function directSyncAll(opts) {
1025
+ const { targetEnv, dryRun } = opts;
1026
+ const targetClient = createClient(targetEnv);
1027
+ // Find all personas with sync tags
1028
+ const personas = await targetClient.getPersonasForTenant();
1029
+ const syncedPersonas = [];
1030
+ for (const p of personas) {
1031
+ const meta = targetClient.getSyncMetadata(p);
1032
+ if (meta) {
1033
+ syncedPersonas.push({ persona: p, meta });
1034
+ }
1035
+ }
1036
+ const results = [];
1037
+ const errors = [];
1038
+ let synced = 0;
1039
+ let skipped = 0;
1040
+ for (const { persona, meta } of syncedPersonas) {
1041
+ try {
1042
+ // Get source client for this persona's master env
1043
+ const sourceClient = createClient(meta.master_env);
1044
+ // Fetch full source persona
1045
+ const sourcePersona = await sourceClient.getPersonaById(meta.master_id);
1046
+ if (!sourcePersona) {
1047
+ errors.push(`Source persona not found: ${meta.master_id} in ${meta.master_env}`);
1048
+ continue;
1049
+ }
1050
+ const result = await directSyncPersonaImpl({
1051
+ sourcePersona,
1052
+ sourceEnv: meta.master_env,
1053
+ targetEnv,
1054
+ sourceClient,
1055
+ targetClient,
1056
+ dryRun,
1057
+ });
1058
+ results.push(result);
1059
+ if (result.error?.includes("Already in sync")) {
1060
+ skipped++;
1061
+ }
1062
+ else {
1063
+ synced++;
1064
+ }
1065
+ }
1066
+ catch (e) {
1067
+ errors.push(`${persona.name}: ${e instanceof Error ? e.message : String(e)}`);
1068
+ }
1069
+ }
1070
+ return {
1071
+ target_env: targetEnv,
1072
+ scanned: syncedPersonas.length,
1073
+ synced,
1074
+ skipped,
1075
+ errors,
1076
+ results,
1077
+ };
1078
+ }
1079
+ // Helper to add env parameter to schema - returns Tool-compatible inputSchema
1080
+ function withEnvParam(props, required = []) {
1081
+ const envs = getAvailableEnvironments();
1082
+ const envNames = envs.map((e) => e.name);
1083
+ return {
1084
+ type: "object",
1085
+ properties: {
1086
+ ...props,
1087
+ env: {
1088
+ type: "string",
1089
+ description: `Target environment. Available: ${envNames.join(", ")}. Default: ${getDefaultEnvName()}`,
1090
+ },
1091
+ },
1092
+ required,
1093
+ };
1094
+ }
1095
+ // ─────────────────────────────────────────────────────────────────────────────
1096
+ // Tool Definitions
1097
+ // ─────────────────────────────────────────────────────────────────────────────
1098
+ //
1099
+ // TOOL STRUCTURE:
1100
+ // 1. CONSOLIDATED TOOLS (9 tools) - Unix CLI pattern with flags
1101
+ // - env, persona, workflow, action, template, knowledge, reference, sync, demo
1102
+ // - Defined in: ./tools-consolidated.ts
1103
+ //
1104
+ // 2. LEGACY TOOLS (40+ tools) - Old separate tools for backwards compatibility
1105
+ // - Defined in: ./tools-legacy.ts
1106
+ // - Enable with EMA_ENABLE_LEGACY_TOOLS=true
1107
+ //
1108
+ // NAMING CONVENTION:
1109
+ // - Tool names are defined as BASE NAMES (e.g., "persona", "list_ai_employees")
1110
+ // - MCP clients prefix with "mcp_{server}_" (e.g., "mcp_ema_persona")
1111
+ //
1112
+ // ─────────────────────────────────────────────────────────────────────────────
1113
+ /**
1114
+ * Generate all available tools based on configuration
1115
+ *
1116
+ * DEFAULT: Only consolidated tools (9 tools, Unix CLI pattern)
1117
+ * OPTIONAL: Set EMA_ENABLE_LEGACY_TOOLS=true to add deprecated tools
1118
+ *
1119
+ * Why consolidated-only by default:
1120
+ * - Prevents LLM confusion from having multiple tools that do the same thing
1121
+ * - Ensures consistent tool usage patterns
1122
+ * - Reduces context window waste from duplicate tool definitions
1123
+ */
1124
+ function generateAllTools() {
1125
+ const envNames = getAvailableEnvironments().map(e => e.name);
1126
+ const defaultEnv = getDefaultEnvName();
1127
+ // Always include consolidated tools (the new unified interface)
1128
+ const consolidatedTools = generateConsolidatedTools(envNames, defaultEnv);
1129
+ // Only include legacy tools if explicitly enabled (for migration period)
1130
+ if (ENABLE_LEGACY_TOOLS) {
1131
+ console.error("[MCP] WARNING: Legacy tools enabled via EMA_ENABLE_LEGACY_TOOLS=true");
1132
+ console.error("[MCP] This is deprecated and will be removed. Migrate to consolidated tools.");
1133
+ const legacyTools = generateLegacyTools(envNames, defaultEnv);
1134
+ // Consolidated tools first (preferred), legacy tools after
1135
+ return [...consolidatedTools, ...legacyTools];
1136
+ }
1137
+ return consolidatedTools;
1138
+ }
1139
+ // Generate tools (called once at module load)
1140
+ const TOOLS = generateAllTools();
1141
+ // ─────────────────────────────────────────────────────────────────────────────
1142
+ // ADDITIONAL TOOLS (not in consolidated or legacy)
1143
+ // These are special-purpose tools that don't fit the standard patterns
1144
+ // ─────────────────────────────────────────────────────────────────────────────
1145
+ const ADDITIONAL_TOOLS = [
1146
+ // ─────────────────────────────────────────────────────────────────────────
1147
+ // NOTE: Legacy tools (get_persona, find_personas, create_ai_employee, etc.)
1148
+ // have been moved to ./tools-legacy.ts for backwards compatibility.
1149
+ //
1150
+ // Consolidated tools (persona, workflow, action, etc.) are generated from
1151
+ // ./tools-consolidated.ts following Unix CLI patterns.
1152
+ //
1153
+ // Only special-purpose tools remain here:
1154
+ // - "workflow" (unified tool) - experimental, bridges old and new patterns
1155
+ // - Demo data tools - special-purpose data generation
1156
+ // ─────────────────────────────────────────────────────────────────────────
1157
+ // INLINE LEGACY TOOLS REMOVED
1158
+ // All legacy tools (get_workflow_action, find_workflow_actions, compare_ai_employees,
1159
+ // list_ai_employee_templates, sync, sync_info, list_auto_builder_agents, etc.)
1160
+ // have been moved to ./tools-legacy.ts
1161
+ // ─────────────────────────────────────────────────────────────────────────
1162
+ // ─────────────────────────────────────────────────────────────────────────────
1163
+ // LEGACY INLINE TOOLS REMOVED - See ./tools-legacy.ts
1164
+ // ─────────────────────────────────────────────────────────────────────────────
1165
+ // The following tools have been removed from this file and are now generated
1166
+ // from tools-legacy.ts: list_auto_builder_agents, get_auto_builder_agent,
1167
+ // suggest_agents_for_use_case, get_workflow_pattern, list_workflow_patterns,
1168
+ // check_type_compatibility, get_widget_reference, get_qualifying_questions,
1169
+ // get_voice_persona_template, validate_workflow_prompt, get_auto_builder_guidance,
1170
+ // get_platform_concept, list_platform_concepts, get_common_mistakes,
1171
+ // get_debug_checklist, get_workflow_execution_model, analyze_workflow,
1172
+ // detect_workflow_issues, validate_workflow_connections, suggest_workflow_fixes,
1173
+ // compare_workflow_versions, get_workflow_metrics, compile_workflow,
1174
+ // upload_data_source, delete_data_source, list_data_sources, get_embedding_status,
1175
+ // toggle_embedding
1176
+ // ─────────────────────────────────────────────────────────────────────────────
1177
+ // The remaining legacy tools are handled by the LEGACY_TOOL_MAPPING in
1178
+ // tools-consolidated.ts for routing to consolidated handlers.
1179
+ //
1180
+ // INLINE DEFINITIONS REMOVED - They are now in tools-legacy.ts
1181
+ // (analyze_workflow, detect_workflow_issues, validate_workflow_connections, suggest_workflow_fixes)
1182
+ // (compare_workflow_versions, get_workflow_metrics) - moved to tools-legacy.ts
1183
+ // ─────────────────────────────────────────────────────────────────────────────
1184
+ // Workflow Compilation - Template-driven, no biased patterns
1185
+ // Read ema://catalog/patterns for pattern references, then construct nodes
1186
+ // ─────────────────────────────────────────────────────────────────────────────
1187
+ {
1188
+ name: "compile_workflow",
1189
+ description: `🔧 Compile workflow from node specification. Template-driven - read \`ema://catalog/patterns\` for pattern templates, construct nodes, then compile.
1190
+
1191
+ **Process**:
1192
+ 1. Read \`ema://catalog/patterns\` for pattern reference
1193
+ 2. Read \`ema://catalog/agents\` for available actions
1194
+ 3. Construct nodes array based on requirements
1195
+ 4. Call compile_workflow with nodes
1196
+ 5. Use deploy_workflow to deploy result
1197
+
1198
+ **Example nodes** (KB search):
1199
+ \`\`\`json
1200
+ [
1201
+ { "id": "trigger", "action_type": "chat_trigger", "display_name": "Trigger" },
1202
+ { "id": "search", "action_type": "search", "display_name": "Search", "inputs": { "query": { "type": "action_output", "action_name": "trigger", "output": "user_query" } } },
1203
+ { "id": "respond", "action_type": "respond_with_sources", "display_name": "Respond", "inputs": { "search_results": { "type": "action_output", "action_name": "search", "output": "search_results" } } }
1204
+ ]
1205
+ \`\`\``,
1206
+ inputSchema: {
1207
+ type: "object",
1208
+ properties: {
1209
+ name: { type: "string", description: "Workflow name" },
1210
+ description: { type: "string", description: "Workflow description" },
1211
+ persona_type: { type: "string", enum: ["voice", "chat", "dashboard"], description: "AI type" },
1212
+ nodes: {
1213
+ type: "array",
1214
+ description: "Node definitions",
1215
+ items: {
1216
+ type: "object",
1217
+ properties: {
1218
+ id: { type: "string", description: "Node ID" },
1219
+ action_type: { type: "string", description: "Action type (e.g., chat_trigger, search, respond_with_sources)" },
1220
+ display_name: { type: "string", description: "Display name" },
1221
+ description: { type: "string", description: "Optional description" },
1222
+ inputs: { type: "object", description: "Input bindings (key: input name, value: binding spec)" },
1223
+ run_if: {
1224
+ type: "object",
1225
+ description: "Conditional execution",
1226
+ properties: {
1227
+ source_action: { type: "string" },
1228
+ source_output: { type: "string", description: "Output name to check" },
1229
+ operator: { type: "string", enum: ["eq", "neq", "gt", "lt", "gte", "lte"], description: "Comparison operator" },
1230
+ value: { type: "string", description: "Value to compare against" },
1231
+ },
1232
+ },
1233
+ categories: {
1234
+ type: "array",
1235
+ description: "Categories for categorizer nodes",
1236
+ items: {
1237
+ type: "object",
1238
+ properties: {
1239
+ name: { type: "string", description: "Category name (e.g., 'Password Reset', 'Fallback')" },
1240
+ description: { type: "string", description: "When this category triggers" },
1241
+ examples: { type: "array", items: { type: "string" }, description: "Example phrases" },
1242
+ },
1243
+ },
1244
+ },
1245
+ tools: {
1246
+ type: "array",
1247
+ description: "External tools for external_action_caller nodes",
1248
+ items: {
1249
+ type: "object",
1250
+ properties: {
1251
+ name: { type: "string", description: "Tool name" },
1252
+ namespace: { type: "string", description: "Tool namespace" },
1253
+ },
1254
+ },
1255
+ },
1256
+ disable_human_interaction: { type: "boolean", description: "If true, disable HITL for this node" },
1257
+ },
1258
+ required: ["id", "action_type", "display_name"],
1259
+ },
1260
+ },
1261
+ result_mappings: {
1262
+ type: "array",
1263
+ description: "Which node outputs map to WORKFLOW_OUTPUT",
1264
+ items: {
1265
+ type: "object",
1266
+ properties: {
1267
+ node_id: { type: "string", description: "Node ID" },
1268
+ output: { type: "string", description: "Output name from the node" },
1269
+ },
1270
+ required: ["node_id", "output"],
1271
+ },
1272
+ },
1273
+ },
1274
+ required: ["name", "description", "persona_type", "nodes", "result_mappings"],
1275
+ },
1276
+ },
1277
+ // ─────────────────────────────────────────────────────────────────────────
1278
+ // Data Source Management
1279
+ // TODO: Add support for 3rd party data sources (Google Drive, SharePoint, Confluence, etc.)
1280
+ // ─────────────────────────────────────────────────────────────────────────
1281
+ {
1282
+ name: "upload_data_source",
1283
+ description: "Upload a file from the local filesystem to an AI Employee's knowledge base. The file will be available for RAG/search if embedding is enabled. IMPORTANT: Upload data sources BEFORE deploying workflows that reference them.",
1284
+ inputSchema: {
1285
+ type: "object",
1286
+ properties: {
1287
+ persona_id: {
1288
+ type: "string",
1289
+ description: "The AI Employee ID to upload the file to",
1290
+ },
1291
+ file_path: {
1292
+ type: "string",
1293
+ description: "Absolute path to the file on the local filesystem",
1294
+ },
1295
+ tags: {
1296
+ type: "string",
1297
+ description: "Optional tags for categorizing the file (default: 'fileUpload')",
1298
+ },
1299
+ env: {
1300
+ type: "string",
1301
+ description: "Target environment. Available: dev, demo, staging. Default: demo",
1302
+ },
1303
+ },
1304
+ required: ["persona_id", "file_path"],
1305
+ },
1306
+ },
1307
+ {
1308
+ name: "delete_data_source",
1309
+ description: "Delete a file from an AI Employee's knowledge base.",
1310
+ inputSchema: {
1311
+ type: "object",
1312
+ properties: {
1313
+ persona_id: {
1314
+ type: "string",
1315
+ description: "The AI Employee ID",
1316
+ },
1317
+ file_id: {
1318
+ type: "string",
1319
+ description: "The file ID to delete (from list_data_sources)",
1320
+ },
1321
+ env: {
1322
+ type: "string",
1323
+ description: "Target environment. Available: dev, demo, staging. Default: demo",
1324
+ },
1325
+ },
1326
+ required: ["persona_id", "file_id"],
1327
+ },
1328
+ },
1329
+ {
1330
+ name: "list_data_sources",
1331
+ description: "List data sources (knowledge base files/documents) configured for an AI Employee, including upload status and file count.",
1332
+ inputSchema: {
1333
+ type: "object",
1334
+ properties: {
1335
+ persona_id: {
1336
+ type: "string",
1337
+ description: "The AI Employee ID to list data sources for",
1338
+ },
1339
+ env: {
1340
+ type: "string",
1341
+ description: "Target environment. Available: dev, demo, staging. Default: demo",
1342
+ },
1343
+ },
1344
+ required: ["persona_id"],
1345
+ },
1346
+ },
1347
+ {
1348
+ name: "get_embedding_status",
1349
+ description: "Get the embedding/RAG status for an AI Employee's knowledge base.",
1350
+ inputSchema: {
1351
+ type: "object",
1352
+ properties: {
1353
+ persona_id: {
1354
+ type: "string",
1355
+ description: "The AI Employee ID",
1356
+ },
1357
+ env: {
1358
+ type: "string",
1359
+ description: "Target environment. Available: dev, demo, staging. Default: demo",
1360
+ },
1361
+ },
1362
+ required: ["persona_id"],
1363
+ },
1364
+ },
1365
+ {
1366
+ name: "toggle_embedding",
1367
+ description: "Enable or disable embedding/RAG for an AI Employee's knowledge base. When enabled, uploaded documents are indexed for semantic search.",
1368
+ inputSchema: {
1369
+ type: "object",
1370
+ properties: {
1371
+ persona_id: {
1372
+ type: "string",
1373
+ description: "The AI Employee ID",
1374
+ },
1375
+ enabled: {
1376
+ type: "boolean",
1377
+ description: "Whether to enable (true) or disable (false) embedding",
1378
+ },
1379
+ env: {
1380
+ type: "string",
1381
+ description: "Target environment. Available: dev, demo, staging. Default: demo",
1382
+ },
1383
+ },
1384
+ required: ["persona_id", "enabled"],
1385
+ },
1386
+ },
1387
+ // ─────────────────────────────────────────────────────────────────────────
1388
+ // Unified Workflow Tool
1389
+ // Accepts any input: natural language, partial spec, full spec, or persona_id
1390
+ // Normalizes to WorkflowIntent → validates → generates → deploys
1391
+ // ─────────────────────────────────────────────────────────────────────────
1392
+ {
1393
+ name: "workflow",
1394
+ description: `🔧 UNIFIED workflow tool. Accepts ANY input and normalizes it.
1395
+
1396
+ **Input types** (auto-detected):
1397
+ - Natural language: "IT helpdesk that creates ServiceNow tickets"
1398
+ - Partial spec: { intents: [...], tools: [...] }
1399
+ - Full nodes spec: { nodes: [...], result_mappings: [...] }
1400
+ - Existing persona: persona_id to analyze/improve
1401
+
1402
+ **Process**:
1403
+ 1. Parse input → WorkflowIntent (normalized representation)
1404
+ 2. Validate completeness → return questions if incomplete
1405
+ 3. Generate workflow (local compile or Auto Builder)
1406
+ 4. Validate output → auto-fix if enabled
1407
+ 5. Deploy if persona_id provided
1408
+
1409
+ **Examples**:
1410
+ \`\`\`
1411
+ workflow("IT helpdesk bot with KB search")
1412
+ workflow({ intents: [{name: "Billing", handler: "search"}], tools: [{namespace: "service_now", action: "Create_Ticket"}] })
1413
+ workflow(persona_id, mode="improve")
1414
+ \`\`\``,
1415
+ inputSchema: withEnvParam({
1416
+ input: {
1417
+ description: "Natural language description, partial spec object, or full nodes spec",
1418
+ },
1419
+ persona_id: {
1420
+ type: "string",
1421
+ description: "For deployment OR to analyze/improve existing workflow",
1422
+ },
1423
+ mode: {
1424
+ type: "string",
1425
+ enum: ["generate", "improve", "analyze"],
1426
+ description: "generate (default): Create new workflow. improve: Fix existing. analyze: Validate only.",
1427
+ },
1428
+ persona_type: {
1429
+ type: "string",
1430
+ enum: ["voice", "chat", "dashboard"],
1431
+ description: "AI type (default: chat, auto-detected from input)",
1432
+ },
1433
+ use_autobuilder: {
1434
+ type: "boolean",
1435
+ description: "Force Auto Builder for generation (default: auto-decide based on complexity)",
1436
+ },
1437
+ auto_deploy: {
1438
+ type: "boolean",
1439
+ description: "Deploy immediately (default: false - returns preview)",
1440
+ },
1441
+ auto_fix: {
1442
+ type: "boolean",
1443
+ description: "Auto-fix detected issues (default: true)",
1444
+ },
1445
+ }, []),
1446
+ },
1447
+ // ─────────────────────────────────────────────────────────────────────────
1448
+ // Demo Data Management
1449
+ // Tools for consolidating, transforming, and preparing mock data for RAG
1450
+ // ─────────────────────────────────────────────────────────────────────────
1451
+ {
1452
+ name: "consolidate_demo_data",
1453
+ description: `Transform normalized JSON source files into RAG-optimized Markdown documents for Ema knowledge bases.
1454
+
1455
+ This tool pre-joins related data (like customers + orders + tickets) into denormalized entity documents that work well with semantic search.
1456
+
1457
+ **Why this matters**: RAG systems can't do SQL-style joins at query time. Data relationships must be explicit in the document content.
1458
+
1459
+ **Supported patterns**:
1460
+ - Entity consolidation (customer with all their orders, tickets, contacts)
1461
+ - Product catalogs with cross-references
1462
+ - Scenario documents for demos
1463
+
1464
+ **Output format**: Markdown files with embedded tables, metadata comments, and narrative summaries optimized for Ema's search agents.`,
1465
+ inputSchema: {
1466
+ type: "object",
1467
+ properties: {
1468
+ source_dir: {
1469
+ type: "string",
1470
+ description: "Path to directory containing source JSON files (e.g., './data/source')",
1471
+ },
1472
+ output_dir: {
1473
+ type: "string",
1474
+ description: "Path to output directory for generated Markdown files (e.g., './data/knowledge-base')",
1475
+ },
1476
+ entity_type: {
1477
+ type: "string",
1478
+ enum: ["customer", "product", "employee", "scenario", "custom"],
1479
+ description: "Type of entity being consolidated. Determines document structure.",
1480
+ },
1481
+ primary_file: {
1482
+ type: "string",
1483
+ description: "Name of the primary JSON file (e.g., 'customers.json')",
1484
+ },
1485
+ joins: {
1486
+ type: "array",
1487
+ items: {
1488
+ type: "object",
1489
+ properties: {
1490
+ file: { type: "string", description: "JSON file to join (e.g., 'orders.json')" },
1491
+ on: { type: "string", description: "Foreign key field (e.g., 'customerId')" },
1492
+ as: { type: "string", description: "Name for the joined data (e.g., 'orders')" },
1493
+ },
1494
+ },
1495
+ description: "Array of files to join with the primary file",
1496
+ },
1497
+ id_field: {
1498
+ type: "string",
1499
+ description: "Field name for the entity ID (default: 'id')",
1500
+ },
1501
+ name_field: {
1502
+ type: "string",
1503
+ description: "Field name for the entity name (default: 'name')",
1504
+ },
1505
+ tags: {
1506
+ type: "string",
1507
+ description: "Comma-separated tags to include in document metadata",
1508
+ },
1509
+ },
1510
+ required: ["source_dir", "output_dir", "entity_type", "primary_file"],
1511
+ },
1512
+ },
1513
+ {
1514
+ name: "generate_demo_document",
1515
+ description: `Generate a single RAG-optimized Markdown document from provided JSON data.
1516
+
1517
+ Use this for:
1518
+ - Creating individual entity documents programmatically
1519
+ - Testing document formats before batch consolidation
1520
+ - Custom document generation with specific data
1521
+
1522
+ The output follows Ema's knowledge base best practices with metadata comments, tables, and narrative context.`,
1523
+ inputSchema: {
1524
+ type: "object",
1525
+ properties: {
1526
+ entity_type: {
1527
+ type: "string",
1528
+ enum: ["customer", "product", "employee", "scenario", "reference"],
1529
+ description: "Type of document to generate",
1530
+ },
1531
+ data: {
1532
+ type: "object",
1533
+ description: "The entity data as a JSON object",
1534
+ },
1535
+ related_data: {
1536
+ type: "object",
1537
+ description: "Related data to include (e.g., { orders: [...], tickets: [...] })",
1538
+ },
1539
+ output_path: {
1540
+ type: "string",
1541
+ description: "Optional: Path to save the generated document. If not provided, returns the content.",
1542
+ },
1543
+ tags: {
1544
+ type: "string",
1545
+ description: "Comma-separated tags for metadata",
1546
+ },
1547
+ },
1548
+ required: ["entity_type", "data"],
1549
+ },
1550
+ },
1551
+ {
1552
+ name: "validate_demo_document",
1553
+ description: `Validate a Markdown document for RAG optimization and Ema compatibility.
1554
+
1555
+ Checks for:
1556
+ - Required metadata comments (ema_entity, ema_id, ema_tags)
1557
+ - Table formatting
1558
+ - Narrative context presence
1559
+ - Cross-reference consistency
1560
+ - Filename conventions`,
1561
+ inputSchema: {
1562
+ type: "object",
1563
+ properties: {
1564
+ file_path: {
1565
+ type: "string",
1566
+ description: "Path to the Markdown file to validate",
1567
+ },
1568
+ content: {
1569
+ type: "string",
1570
+ description: "Alternatively, provide the document content directly",
1571
+ },
1572
+ },
1573
+ required: [],
1574
+ },
1575
+ },
1576
+ {
1577
+ name: "get_demo_data_template",
1578
+ description: `Get a template for demo data documents based on entity type.
1579
+
1580
+ Returns:
1581
+ - Source JSON schema (what fields to include)
1582
+ - Output Markdown template
1583
+ - Best practices for the entity type
1584
+ - Example data`,
1585
+ inputSchema: {
1586
+ type: "object",
1587
+ properties: {
1588
+ entity_type: {
1589
+ type: "string",
1590
+ enum: ["customer", "product", "employee", "scenario", "reference"],
1591
+ description: "Type of template to get",
1592
+ },
1593
+ include_example: {
1594
+ type: "boolean",
1595
+ description: "Include example data (default: true)",
1596
+ },
1597
+ },
1598
+ required: ["entity_type"],
1599
+ },
1600
+ },
1601
+ // ═══════════════════════════════════════════════════════════════════════════
1602
+ // CONSOLIDATED TOOLS (Unix CLI pattern)
1603
+ // These 9 tools follow Unix conventions: single command with flags
1604
+ // ═══════════════════════════════════════════════════════════════════════════
1605
+ ...generateConsolidatedTools(getAvailableEnvironments().map(e => e.name), getDefaultEnvName()),
1606
+ ];
1607
+ const toolHandlers = {
1608
+ // Environment Management
1609
+ list_environments: async () => {
1610
+ const envs = getAvailableEnvironments();
1611
+ const defaultEnv = getDefaultEnvName();
1612
+ return {
1613
+ default_environment: defaultEnv,
1614
+ environments: envs.map((e) => ({
1615
+ name: e.name,
1616
+ url: e.baseUrl,
1617
+ is_default: e.name === defaultEnv,
1618
+ })),
1619
+ };
1620
+ },
1621
+ // ─────────────────────────────────────────────────────────────────────────
1622
+ // AI Employee Handlers (Consolidated)
1623
+ // ─────────────────────────────────────────────────────────────────────────
1624
+ get_persona: async (args) => {
1625
+ const client = createClient(args.env);
1626
+ const identifier = String(args.identifier);
1627
+ const includeWorkflow = args.include_workflow === true;
1628
+ const includeFingerprint = args.include_fingerprint === true;
1629
+ // Auto-detect: UUIDs are 36 chars with dashes, otherwise it's a name
1630
+ const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(identifier);
1631
+ let persona;
1632
+ if (isUUID) {
1633
+ // Fetch by ID - use full fetch if workflow needed, otherwise list
1634
+ if (includeWorkflow || includeFingerprint) {
1635
+ persona = (await client.getPersonaById(identifier)) ?? undefined;
1636
+ }
1637
+ else {
1638
+ const personas = await client.getPersonasForTenant();
1639
+ persona = personas.find((p) => p.id === identifier);
1640
+ }
1641
+ }
1642
+ else {
1643
+ // Fetch by name - always need to list first to find ID
1644
+ const personas = await client.getPersonasForTenant();
1645
+ persona = personas.find((p) => p.name === identifier);
1646
+ // If found and need workflow, fetch full details
1647
+ if (persona && (includeWorkflow || includeFingerprint)) {
1648
+ persona = (await client.getPersonaById(persona.id)) ?? undefined;
1649
+ }
1650
+ }
1651
+ if (!persona) {
1652
+ throw new Error(`AI Employee not found: ${identifier} (searched by ${isUUID ? "ID" : "name"})`);
1653
+ }
1654
+ // Build response
1655
+ const result = {
1656
+ environment: client["env"].name,
1657
+ ai_employee: persona,
1658
+ };
1659
+ // Add fingerprint if requested
1660
+ if (includeFingerprint) {
1661
+ result.fingerprint = fingerprintPersona(persona);
1662
+ }
1663
+ return result;
1664
+ },
1665
+ find_personas: async (args) => {
1666
+ const client = createClient(args.env);
1667
+ let personas = await client.getPersonasForTenant();
1668
+ // Apply filters
1669
+ if (args.query) {
1670
+ const q = String(args.query).toLowerCase();
1671
+ personas = personas.filter((p) => p.name?.toLowerCase().includes(q));
1672
+ }
1673
+ if (args.status) {
1674
+ const f = String(args.status).toLowerCase();
1675
+ personas = personas.filter((p) => p.status?.toLowerCase() === f);
1676
+ }
1677
+ if (args.trigger_type) {
1678
+ const f = String(args.trigger_type).toLowerCase();
1679
+ personas = personas.filter((p) => p.trigger_type?.toLowerCase() === f);
1680
+ }
1681
+ if (args.access_level) {
1682
+ const f = String(args.access_level).toLowerCase();
1683
+ personas = personas.filter((p) => p.access_level?.toLowerCase() === f);
1684
+ }
1685
+ if (typeof args.has_workflow === "boolean") {
1686
+ personas = personas.filter((p) => args.has_workflow ? !!p.workflow_id : !p.workflow_id);
1687
+ }
1688
+ if (typeof args.embedding_enabled === "boolean") {
1689
+ personas = personas.filter((p) => p.embedding_enabled === args.embedding_enabled);
1690
+ }
1691
+ const limit = typeof args.limit === "number" ? args.limit : 50;
1692
+ personas = personas.slice(0, limit);
1693
+ return {
1694
+ environment: client["env"].name,
1695
+ count: personas.length,
1696
+ ai_employees: personas.map((p) => ({
1697
+ id: p.id,
1698
+ name: p.name,
1699
+ description: p.description,
1700
+ status: p.status,
1701
+ template_id: p.template_id ?? p.templateId,
1702
+ workflow_id: p.workflow_id,
1703
+ trigger_type: p.trigger_type,
1704
+ access_level: p.access_level,
1705
+ embedding_enabled: p.embedding_enabled,
1706
+ })),
1707
+ };
1708
+ },
1709
+ // ─────────────────────────────────────────────────────────────────────────
1710
+ // AI Employee CRUD Handlers
1711
+ // ─────────────────────────────────────────────────────────────────────────
1712
+ create_ai_employee: async (args) => {
1713
+ const client = createClient(args.env);
1714
+ // Default template IDs for common persona types
1715
+ const DEFAULT_TEMPLATES = {
1716
+ voice: "00000000-0000-0000-0000-00000000001e", // Voice AI template
1717
+ chat: "00000000-0000-0000-0000-000000000004", // Chat AI template
1718
+ dashboard: "00000000-0000-0000-0000-000000000002", // Dashboard AI template
1719
+ };
1720
+ // Determine template_id: explicit > default based on persona_type
1721
+ let templateId = args.template_id ? String(args.template_id) : undefined;
1722
+ const sourcePersonaId = args.source_persona_id ? String(args.source_persona_id) : undefined;
1723
+ // If no template_id or source_persona_id, use default template based on persona_type
1724
+ if (!templateId && !sourcePersonaId) {
1725
+ const personaType = args.persona_type ? String(args.persona_type).toLowerCase() : null;
1726
+ if (personaType && DEFAULT_TEMPLATES[personaType]) {
1727
+ templateId = DEFAULT_TEMPLATES[personaType];
1728
+ }
1729
+ else {
1730
+ throw new Error("Must provide template_id, source_persona_id, or persona_type ('voice', 'chat', 'dashboard')");
1731
+ }
1732
+ }
1733
+ const req = {
1734
+ name: String(args.name),
1735
+ description: args.description ? String(args.description) : undefined,
1736
+ template_id: templateId,
1737
+ source_persona_id: sourcePersonaId,
1738
+ // Note: trigger_type is determined by template, not passed separately
1739
+ };
1740
+ const result = await client.createAiEmployee(req);
1741
+ return {
1742
+ environment: client["env"].name,
1743
+ success: true,
1744
+ persona_id: result.persona_id ?? result.id,
1745
+ status: result.status ?? "created",
1746
+ template_used: templateId,
1747
+ };
1748
+ },
1749
+ update_ai_employee: async (args) => {
1750
+ const client = createClient(args.env);
1751
+ const personaId = String(args.persona_id);
1752
+ // Use getPersonaById for complete data (including full proto_config)
1753
+ const existing = await client.getPersonaById(personaId);
1754
+ if (!existing)
1755
+ throw new Error(`AI Employee not found: ${personaId}`);
1756
+ // Helper to validate widget names
1757
+ const isValidWidget = (w) => {
1758
+ const name = w.name;
1759
+ return typeof name === "string" && name.trim().length > 0;
1760
+ };
1761
+ // Merge proto_config: start with existing, overlay with provided
1762
+ // This ensures we don't lose existing widget configs when updating
1763
+ // Also sanitize existing widgets to remove any with empty names (can happen from UI bugs)
1764
+ let mergedProtoConfig = existing.proto_config ?? {};
1765
+ if (mergedProtoConfig.widgets && Array.isArray(mergedProtoConfig.widgets)) {
1766
+ const sanitizedWidgets = mergedProtoConfig.widgets.filter(isValidWidget);
1767
+ mergedProtoConfig = { ...mergedProtoConfig, widgets: sanitizedWidgets };
1768
+ }
1769
+ if (args.proto_config && typeof args.proto_config === "object") {
1770
+ const providedConfig = args.proto_config;
1771
+ // Deep merge widgets if both exist
1772
+ // Widgets use: name (string like "voiceSettings"), type (number like 38)
1773
+ // Inner configs are stored under a key matching the widget's name (e.g., widget.voiceSettings: {...})
1774
+ const existingWidgetsRaw = mergedProtoConfig.widgets;
1775
+ const newWidgetsRaw = providedConfig.widgets;
1776
+ if (Array.isArray(existingWidgetsRaw) || Array.isArray(newWidgetsRaw)) {
1777
+ // Filter using isValidWidget helper (already defined above)
1778
+ const existingWidgets = (existingWidgetsRaw ?? []).filter(isValidWidget);
1779
+ const newWidgets = (newWidgetsRaw ?? []).filter(isValidWidget);
1780
+ // Merge widgets by 'name' field (e.g., "voiceSettings", "conversationSettings")
1781
+ const widgetMap = new Map();
1782
+ for (const w of existingWidgets) {
1783
+ const key = String(w.name);
1784
+ widgetMap.set(key, { ...w });
1785
+ }
1786
+ for (const w of newWidgets) {
1787
+ const key = String(w.name);
1788
+ const existingWidget = widgetMap.get(key);
1789
+ if (existingWidget) {
1790
+ // Deep merge inner config object (named same as widget's 'name')
1791
+ // e.g., for widget with name="voiceSettings", merge widget.voiceSettings
1792
+ const widgetName = String(w.name);
1793
+ const innerConfig = w[widgetName];
1794
+ const existingInner = existingWidget[widgetName];
1795
+ const mergedWidget = {
1796
+ ...existingWidget,
1797
+ ...w,
1798
+ };
1799
+ // Deep merge the inner config if both exist
1800
+ if (innerConfig && existingInner) {
1801
+ mergedWidget[widgetName] = { ...existingInner, ...innerConfig };
1802
+ }
1803
+ widgetMap.set(key, mergedWidget);
1804
+ }
1805
+ else {
1806
+ widgetMap.set(key, { ...w });
1807
+ }
1808
+ }
1809
+ // Only include widgets array if we have valid widgets
1810
+ const mergedWidgets = Array.from(widgetMap.values());
1811
+ if (mergedWidgets.length > 0) {
1812
+ mergedProtoConfig = { ...mergedProtoConfig, ...providedConfig, widgets: mergedWidgets };
1813
+ }
1814
+ else {
1815
+ // No valid widgets - exclude the widgets field entirely
1816
+ const { widgets: _unused, ...restProvidedConfig } = providedConfig;
1817
+ mergedProtoConfig = { ...mergedProtoConfig, ...restProvidedConfig };
1818
+ }
1819
+ }
1820
+ else {
1821
+ // Simple shallow merge for non-widget fields
1822
+ mergedProtoConfig = { ...mergedProtoConfig, ...providedConfig };
1823
+ }
1824
+ }
1825
+ // Check if user is trying to pass workflow - redirect them to deploy_workflow
1826
+ if (args.workflow) {
1827
+ return {
1828
+ environment: client["env"].name,
1829
+ success: false,
1830
+ error: "workflow_parameter_deprecated",
1831
+ message: "The 'workflow' parameter has been removed from update_ai_employee. Use deploy_workflow instead - it provides validation, auto-fix, and better error handling for workflow changes.",
1832
+ suggestion: {
1833
+ tool: "deploy_workflow",
1834
+ parameters: {
1835
+ persona_id: personaId,
1836
+ workflow_def: "your_workflow_here",
1837
+ validate_first: true,
1838
+ auto_fix: true,
1839
+ },
1840
+ },
1841
+ };
1842
+ }
1843
+ const req = {
1844
+ persona_id: personaId,
1845
+ name: args.name ? String(args.name) : undefined,
1846
+ description: args.description ? String(args.description) : undefined,
1847
+ proto_config: mergedProtoConfig,
1848
+ embedding_enabled: typeof args.embedding_enabled === "boolean" ? args.embedding_enabled : undefined,
1849
+ enabled_by_user: typeof args.enabled_by_user === "boolean" ? args.enabled_by_user : undefined,
1850
+ };
1851
+ await client.updateAiEmployee(req);
1852
+ return {
1853
+ environment: client["env"].name,
1854
+ success: true,
1855
+ persona_id: personaId,
1856
+ persona_name: existing.name,
1857
+ updated_fields: {
1858
+ name: !!args.name,
1859
+ description: !!args.description,
1860
+ proto_config: !!args.proto_config,
1861
+ embedding_enabled: typeof args.embedding_enabled === "boolean",
1862
+ enabled_by_user: typeof args.enabled_by_user === "boolean",
1863
+ },
1864
+ note: "For workflow changes, use deploy_workflow which provides validation and auto-fix.",
1865
+ };
1866
+ },
1867
+ deploy_workflow: async (args) => {
1868
+ const client = createClient(args.env);
1869
+ const personaId = String(args.persona_id);
1870
+ const validateFirst = args.validate_first !== false; // default true
1871
+ const autoFix = args.auto_fix === true; // default false
1872
+ // Get existing persona with full details
1873
+ const persona = await client.getPersonaById(personaId);
1874
+ if (!persona)
1875
+ throw new Error(`AI Employee not found: ${personaId}`);
1876
+ let workflowDef = args.workflow_def;
1877
+ const protoConfig = args.proto_config;
1878
+ if (!workflowDef && !protoConfig) {
1879
+ throw new Error("At least one of workflow_def or proto_config must be provided");
1880
+ }
1881
+ // Sanitize workflow_def to prevent server-side crashes
1882
+ if (workflowDef) {
1883
+ // Fix enumTypes - remove entries with empty/missing names (causes server panic)
1884
+ // EnumType structure in proto: { name: { name: { name: "string", namespaces: [] } }, options: [...] }
1885
+ // The backend calls FlattenNamespacedName(enum.Name.Name) which panics if Name.Name is nil
1886
+ const enumTypes = workflowDef.enumTypes;
1887
+ if (Array.isArray(enumTypes)) {
1888
+ const validEnumTypes = enumTypes.filter(e => {
1889
+ // Navigate the nested structure: e.name.name.name
1890
+ const outerName = e.name;
1891
+ const innerName = outerName?.name;
1892
+ const actualName = innerName?.name;
1893
+ // Must have the full structure with a non-empty string name
1894
+ return typeof actualName === "string" && actualName.trim().length > 0;
1895
+ });
1896
+ if (validEnumTypes.length > 0) {
1897
+ workflowDef.enumTypes = validEnumTypes;
1898
+ }
1899
+ else {
1900
+ // Remove empty enumTypes entirely
1901
+ delete workflowDef.enumTypes;
1902
+ }
1903
+ }
1904
+ // Ensure all actions have a 'name' field (node identifier)
1905
+ const actions = workflowDef.actions;
1906
+ if (Array.isArray(actions)) {
1907
+ for (const action of actions) {
1908
+ // If action has 'actionName' but not 'name', fix it
1909
+ if (!action.name && action.actionName) {
1910
+ action.name = action.actionName;
1911
+ }
1912
+ // Ensure name is a non-empty string
1913
+ if (!action.name || (typeof action.name === "string" && action.name.trim().length === 0)) {
1914
+ // Try to derive from action type
1915
+ const actionType = action.action;
1916
+ if (actionType?.name?.name) {
1917
+ action.name = `${actionType.name.name}_${actions.indexOf(action)}`;
1918
+ }
1919
+ }
1920
+ // CRITICAL: Normalize action structure to include required empty fields
1921
+ // The backend expects these fields to exist (even if empty) or it may 500
1922
+ if (action.typeArguments === undefined) {
1923
+ action.typeArguments = {};
1924
+ }
1925
+ if (action.tools === undefined) {
1926
+ action.tools = [];
1927
+ }
1928
+ if (action.disableHumanInteraction === undefined) {
1929
+ action.disableHumanInteraction = false;
1930
+ }
1931
+ // Ensure displaySettings exists and has required structure
1932
+ if (!action.displaySettings) {
1933
+ action.displaySettings = {
1934
+ displayName: String(action.name || ""),
1935
+ coordinates: { x: 0, y: 0 },
1936
+ description: "",
1937
+ showConfig: 0,
1938
+ };
1939
+ }
1940
+ else {
1941
+ const ds = action.displaySettings;
1942
+ if (ds.description === undefined)
1943
+ ds.description = "";
1944
+ if (ds.showConfig === undefined)
1945
+ ds.showConfig = 0;
1946
+ }
1947
+ // Ensure inputs exists
1948
+ if (action.inputs === undefined) {
1949
+ action.inputs = {};
1950
+ }
1951
+ // Normalize runIf operator enum: backend proto expects numeric values (e.g. 1),
1952
+ // but some JSON payloads use string enums (e.g. "OPERATOR_EQ") which can 500.
1953
+ const runIf = action.runIf;
1954
+ if (runIf && typeof runIf === "object") {
1955
+ const op = runIf.operator;
1956
+ if (typeof op === "string") {
1957
+ const opMap = {
1958
+ OPERATOR_EQ: 1,
1959
+ OPERATOR_NEQ: 2,
1960
+ OPERATOR_GT: 3,
1961
+ OPERATOR_GTE: 4,
1962
+ OPERATOR_LT: 5,
1963
+ OPERATOR_LTE: 6,
1964
+ OPERATOR_IN: 7,
1965
+ OPERATOR_NOT_IN: 8,
1966
+ };
1967
+ if (opMap[op] !== undefined)
1968
+ runIf.operator = opMap[op];
1969
+ }
1970
+ }
1971
+ }
1972
+ }
1973
+ }
1974
+ // Get existing workflow info
1975
+ const existingWorkflow = persona.workflow_def;
1976
+ const existingWorkflowId = persona.workflow_id;
1977
+ // Copy missing top-level workflow fields from existing workflow.
1978
+ // Some backends are strict about presence of these keys.
1979
+ if (workflowDef && existingWorkflow) {
1980
+ const copyIfMissing = (k) => {
1981
+ if (workflowDef[k] === undefined && existingWorkflow[k] !== undefined) {
1982
+ workflowDef[k] = JSON.parse(JSON.stringify(existingWorkflow[k]));
1983
+ }
1984
+ };
1985
+ copyIfMissing("workflowInputs");
1986
+ copyIfMissing("namedResults");
1987
+ copyIfMissing("displayName");
1988
+ copyIfMissing("description");
1989
+ copyIfMissing("namedResultsEditable");
1990
+ copyIfMissing("namedResultsEnabled");
1991
+ copyIfMissing("edges");
1992
+ }
1993
+ // Determine deployment strategy
1994
+ const hasExistingWorkflow = !!existingWorkflowId;
1995
+ let deploymentMethod = hasExistingWorkflow ? "direct_api" : "autobuilder";
1996
+ // Transform workflow to match target persona (same approach sync uses)
1997
+ // This ensures workflowName.namespaces contains the correct persona ID
1998
+ if (workflowDef && hasExistingWorkflow && existingWorkflow) {
1999
+ // Extract source persona ID from incoming workflow (if any)
2000
+ const incomingWfName = workflowDef.workflowName;
2001
+ const sourcePersonaId = incomingWfName?.name?.namespaces?.[2]; // Usually at index 2: ["ema", "templates", "<persona_id>"]
2002
+ if (sourcePersonaId && sourcePersonaId !== personaId) {
2003
+ // Workflow was generated for a different persona - transform it
2004
+ workflowDef = transformWorkflowForTarget(workflowDef, sourcePersonaId, personaId);
2005
+ }
2006
+ // CRITICAL: Copy the exact workflowName structure from existing workflow
2007
+ // The API validates that the workflow name matches exactly
2008
+ const existingWfName = existingWorkflow.workflowName;
2009
+ if (existingWfName) {
2010
+ workflowDef.workflowName = JSON.parse(JSON.stringify(existingWfName));
2011
+ }
2012
+ }
2013
+ else if (workflowDef && hasExistingWorkflow && !existingWorkflow) {
2014
+ // Has workflow_id but no workflow_def (edge case) - construct workflowName from workflow_id
2015
+ // workflow_id format: "ema.templates.<persona_id>.default" or similar
2016
+ const parts = existingWorkflowId.split(".");
2017
+ if (parts.length >= 3) {
2018
+ workflowDef.workflowName = {
2019
+ name: {
2020
+ namespaces: parts.slice(0, -1),
2021
+ name: parts[parts.length - 1],
2022
+ },
2023
+ };
2024
+ }
2025
+ }
2026
+ // Track fixes applied
2027
+ const appliedFixes = [];
2028
+ let fixAttempted = false;
2029
+ // Validate workflow if requested and provided
2030
+ let validationResults = { valid: true, issues: [] };
2031
+ if (validateFirst && workflowDef) {
2032
+ const analysis = analyzeWorkflow(workflowDef, {
2033
+ persona_id: personaId,
2034
+ persona_name: persona.name ?? "Unknown"
2035
+ });
2036
+ if (!analysis.validation_passed) {
2037
+ validationResults.valid = false;
2038
+ validationResults.issues = analysis.issues ?? [];
2039
+ // Auto-fix if enabled
2040
+ if (autoFix && validationResults.issues.length > 0) {
2041
+ fixAttempted = true;
2042
+ const fixResult = applyWorkflowFixes(workflowDef, validationResults.issues, persona);
2043
+ workflowDef = fixResult.fixedWorkflow;
2044
+ appliedFixes.push(...fixResult.appliedFixes);
2045
+ // Re-validate after fixes
2046
+ const reAnalysis = analyzeWorkflow(workflowDef, {
2047
+ persona_id: personaId,
2048
+ persona_name: persona.name ?? "Unknown"
2049
+ });
2050
+ validationResults.valid = reAnalysis.validation_passed;
2051
+ validationResults.issues = reAnalysis.issues ?? [];
2052
+ }
2053
+ }
2054
+ }
2055
+ // If validation failed and not forcing, return the issues
2056
+ if (!validationResults.valid) {
2057
+ return {
2058
+ environment: client["env"].name,
2059
+ success: false,
2060
+ persona_id: personaId,
2061
+ persona_name: persona.name,
2062
+ validation_failed: true,
2063
+ issues: validationResults.issues,
2064
+ auto_fix_attempted: fixAttempted,
2065
+ fixes_applied: appliedFixes.filter(f => f.applied),
2066
+ fixes_failed: appliedFixes.filter(f => !f.applied),
2067
+ remaining_issues: validationResults.issues.length,
2068
+ hint: autoFix
2069
+ ? "Some issues could not be auto-fixed. Review the remaining issues and fix manually, or set validate_first=false to skip validation (not recommended)"
2070
+ : "Fix the issues above, enable auto_fix=true for automatic fixes, or set validate_first=false to skip validation (not recommended)",
2071
+ };
2072
+ }
2073
+ // Helper to validate widget names
2074
+ const isValidWidget = (w) => {
2075
+ const name = w.name;
2076
+ return typeof name === "string" && name.trim().length > 0;
2077
+ };
2078
+ // Merge proto_config with existing, preserving widgets
2079
+ // Widgets use: name (string like "voiceSettings"), type (number like 38)
2080
+ // Inner configs are stored under a key matching the widget's name
2081
+ // Also sanitize existing widgets to remove any with empty names (can happen from UI bugs)
2082
+ let mergedProtoConfig = persona.proto_config ?? {};
2083
+ if (mergedProtoConfig.widgets && Array.isArray(mergedProtoConfig.widgets)) {
2084
+ const sanitizedWidgets = mergedProtoConfig.widgets.filter(isValidWidget);
2085
+ mergedProtoConfig = { ...mergedProtoConfig, widgets: sanitizedWidgets };
2086
+ }
2087
+ if (protoConfig) {
2088
+ // Deep merge widgets if both exist
2089
+ const existingWidgetsRaw = mergedProtoConfig.widgets;
2090
+ const newWidgetsRaw = protoConfig.widgets;
2091
+ if (Array.isArray(existingWidgetsRaw) || Array.isArray(newWidgetsRaw)) {
2092
+ // Filter using isValidWidget helper
2093
+ const existingWidgets = (existingWidgetsRaw ?? []).filter(isValidWidget);
2094
+ const newWidgets = (newWidgetsRaw ?? []).filter(isValidWidget);
2095
+ // Merge widgets by 'name' field (e.g., "voiceSettings", "conversationSettings")
2096
+ const widgetMap = new Map();
2097
+ for (const w of existingWidgets) {
2098
+ const key = String(w.name);
2099
+ widgetMap.set(key, { ...w });
2100
+ }
2101
+ for (const w of newWidgets) {
2102
+ const key = String(w.name);
2103
+ const existingWidget = widgetMap.get(key);
2104
+ if (existingWidget) {
2105
+ // Deep merge inner config object (named same as widget's 'name')
2106
+ const widgetName = String(w.name);
2107
+ const innerConfig = w[widgetName];
2108
+ const existingInner = existingWidget[widgetName];
2109
+ const mergedWidget = {
2110
+ ...existingWidget,
2111
+ ...w,
2112
+ };
2113
+ // Deep merge the inner config if both exist
2114
+ if (innerConfig && existingInner) {
2115
+ mergedWidget[widgetName] = { ...existingInner, ...innerConfig };
2116
+ }
2117
+ widgetMap.set(key, mergedWidget);
2118
+ }
2119
+ else {
2120
+ widgetMap.set(key, { ...w });
2121
+ }
2122
+ }
2123
+ // Only include widgets array if we have valid widgets
2124
+ const mergedWidgets = Array.from(widgetMap.values());
2125
+ if (mergedWidgets.length > 0) {
2126
+ mergedProtoConfig = { ...mergedProtoConfig, ...protoConfig, widgets: mergedWidgets };
2127
+ }
2128
+ else {
2129
+ // No valid widgets - exclude the widgets field entirely
2130
+ const { widgets: _unused, ...restProtoConfig } = protoConfig;
2131
+ mergedProtoConfig = { ...mergedProtoConfig, ...restProtoConfig };
2132
+ }
2133
+ }
2134
+ else {
2135
+ mergedProtoConfig = { ...mergedProtoConfig, ...protoConfig };
2136
+ }
2137
+ }
2138
+ // Build update request
2139
+ const req = {
2140
+ persona_id: personaId,
2141
+ proto_config: mergedProtoConfig,
2142
+ workflow: workflowDef,
2143
+ };
2144
+ // Deployment attempt with automatic fallback
2145
+ let deployedVia = "direct_api";
2146
+ let autobuilderResult;
2147
+ if (deploymentMethod === "direct_api") {
2148
+ try {
2149
+ await client.updateAiEmployee(req);
2150
+ }
2151
+ catch (err) {
2152
+ const errorMessage = err instanceof Error ? err.message : String(err);
2153
+ // If direct API fails due to "no existing workflow", try Auto Builder
2154
+ if (errorMessage.includes("Cannot set persona workflow without existing workflow") && workflowDef) {
2155
+ deploymentMethod = "autobuilder";
2156
+ }
2157
+ else if (errorMessage.includes("Workflow name does not match")) {
2158
+ // This shouldn't happen with our name sync, but handle gracefully
2159
+ throw new Error(`Workflow deployment failed: The workflow structure may be incompatible. ` +
2160
+ `Please use the Ema UI Auto Builder to make changes to this persona's workflow. ` +
2161
+ `(Technical: ${errorMessage})`);
2162
+ }
2163
+ else {
2164
+ // Other API errors - surface clearly
2165
+ throw new Error(`Workflow deployment failed: ${errorMessage}`);
2166
+ }
2167
+ }
2168
+ }
2169
+ // Auto Builder fallback for personas without existing workflows
2170
+ if (deploymentMethod === "autobuilder" && workflowDef) {
2171
+ try {
2172
+ // Generate a prompt that asks the Auto Builder to deploy this specific workflow
2173
+ const workflowSummary = summarizeWorkflow(workflowDef);
2174
+ const prompt = `Deploy this workflow to the persona. The workflow has the following structure:\n\n${workflowSummary}\n\nPlease create and save this workflow.`;
2175
+ // Use the iterate workflow method which handles Auto Builder discovery
2176
+ autobuilderResult = await client.iterateWorkflow(personaId, prompt, { newConversation: true });
2177
+ deployedVia = "autobuilder";
2178
+ // Also update proto_config if provided (Auto Builder may not handle this)
2179
+ if (protoConfig) {
2180
+ await client.updateAiEmployee({
2181
+ persona_id: personaId,
2182
+ proto_config: mergedProtoConfig,
2183
+ });
2184
+ }
2185
+ }
2186
+ catch (autoErr) {
2187
+ const autoErrorMessage = autoErr instanceof Error ? autoErr.message : String(autoErr);
2188
+ // If Auto Builder also fails, provide clear guidance
2189
+ if (autoErrorMessage.includes("No Autobuilder persona found")) {
2190
+ throw new Error(`Cannot deploy workflow: This persona has no existing workflow, and the Ema Auto Builder is not available in this tenant. ` +
2191
+ `Please contact your Ema administrator to enable the Auto Builder, or create a new persona from a workflow template.`);
2192
+ }
2193
+ throw new Error(`Workflow deployment via Auto Builder failed: ${autoErrorMessage}`);
2194
+ }
2195
+ }
2196
+ return {
2197
+ environment: client["env"].name,
2198
+ success: true,
2199
+ persona_id: personaId,
2200
+ persona_name: persona.name,
2201
+ deployed: {
2202
+ workflow_def: !!workflowDef,
2203
+ proto_config: !!protoConfig,
2204
+ },
2205
+ deployment_method: deployedVia,
2206
+ validation_passed: validationResults.valid,
2207
+ auto_fix_applied: fixAttempted && appliedFixes.some(f => f.applied),
2208
+ fixes_applied: appliedFixes.filter(f => f.applied),
2209
+ autobuilder_response: autobuilderResult?.response,
2210
+ note: deployedVia === "autobuilder"
2211
+ ? "Workflow deployed via Ema Auto Builder (persona had no existing workflow). Test in the Ema simulator to verify behavior."
2212
+ : fixAttempted && appliedFixes.some(f => f.applied)
2213
+ ? `Workflow deployed successfully with ${appliedFixes.filter(f => f.applied).length} auto-fix(es) applied. Test in the Ema simulator to verify behavior.`
2214
+ : workflowDef
2215
+ ? "Workflow deployed successfully. Test in the Ema simulator to verify behavior."
2216
+ : "Proto config updated successfully.",
2217
+ };
2218
+ },
2219
+ optimize_workflow: async (args) => {
2220
+ const client = createClient(args.env);
2221
+ const identifier = args.identifier ? String(args.identifier) : undefined;
2222
+ const targetPersonaId = args.persona_id ? String(args.persona_id) : undefined;
2223
+ const prompt = args.prompt ? String(args.prompt) : undefined;
2224
+ const personaType = args.type ?? "chat";
2225
+ const preview = args.preview === true;
2226
+ // Validate inputs
2227
+ if (!identifier && !targetPersonaId && !prompt) {
2228
+ throw new Error('Provide either: identifier (to fix existing), or persona_id + prompt (to enhance existing)');
2229
+ }
2230
+ let persona = null;
2231
+ let workflowDef;
2232
+ let personaId;
2233
+ let enhancementPrompt = prompt; // Store prompt for enhancement logging
2234
+ // === ALWAYS START BY FETCHING EXISTING WORKFLOW ===
2235
+ // Brownfield: fix existing + apply enhancements from prompt
2236
+ // The prompt describes what to ADD or CHANGE, not a complete replacement
2237
+ const lookupId = identifier ?? targetPersonaId;
2238
+ const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(lookupId);
2239
+ if (isUUID) {
2240
+ persona = await client.getPersonaById(lookupId);
2241
+ }
2242
+ else {
2243
+ // Search by name
2244
+ const personas = await client.getPersonasForTenant();
2245
+ const match = personas.find((p) => p.name?.toLowerCase() === lookupId.toLowerCase() ||
2246
+ p.name?.toLowerCase().includes(lookupId.toLowerCase()));
2247
+ if (match) {
2248
+ persona = await client.getPersonaById(match.id);
2249
+ }
2250
+ }
2251
+ if (!persona) {
2252
+ throw new Error(`AI Employee "${lookupId}" not found. Check the name or ID.`);
2253
+ }
2254
+ personaId = persona.id;
2255
+ workflowDef = persona.workflow_def;
2256
+ if (!workflowDef) {
2257
+ return {
2258
+ success: false,
2259
+ persona: persona.name,
2260
+ status: "⚠️ No Workflow",
2261
+ message: "This AI Employee has no workflow. Use prompt parameter to generate one: optimize_workflow(persona_id=\"...\", prompt=\"description of what it should do\")",
2262
+ };
2263
+ }
2264
+ // Step 2: Analyze workflow for issues
2265
+ const analysis = analyzeWorkflow(workflowDef, {
2266
+ persona_id: personaId,
2267
+ persona_name: persona.name ?? "Unknown",
2268
+ });
2269
+ const issues = analysis.issues ?? [];
2270
+ const criticalIssues = issues.filter(i => i.severity === "critical");
2271
+ // If no issues, workflow is healthy
2272
+ if (issues.length === 0) {
2273
+ const result = {
2274
+ success: true,
2275
+ persona: persona.name,
2276
+ status: "✅ Workflow is healthy",
2277
+ nodes: analysis.summary?.total_nodes ?? 0,
2278
+ message: "No issues found - workflow is already optimized!",
2279
+ };
2280
+ // If enhancement prompt was provided, note that it can't be auto-applied
2281
+ if (enhancementPrompt) {
2282
+ result.enhancement_note = `Enhancement requested: "${enhancementPrompt}". Since there are no issues to fix, use the Ema UI Auto Builder to make this change manually.`;
2283
+ }
2284
+ return result;
2285
+ }
2286
+ // Step 3: Apply fixes
2287
+ let fixedWorkflow = workflowDef;
2288
+ const appliedFixes = [];
2289
+ if (issues.length > 0) {
2290
+ const fixResult = applyWorkflowFixes(workflowDef, issues, persona);
2291
+ fixedWorkflow = fixResult.fixedWorkflow;
2292
+ appliedFixes.push(...fixResult.appliedFixes);
2293
+ }
2294
+ // Re-analyze after fixes
2295
+ const postFixAnalysis = analyzeWorkflow(fixedWorkflow, {
2296
+ persona_id: personaId,
2297
+ persona_name: persona.name ?? "Unknown",
2298
+ });
2299
+ const remainingIssues = postFixAnalysis.issues ?? [];
2300
+ const remainingCritical = remainingIssues.filter(i => i.severity === "critical");
2301
+ // If preview mode, show what would happen without deploying
2302
+ if (preview) {
2303
+ const fixedCount = appliedFixes.filter(f => f.applied).length;
2304
+ const failedCount = appliedFixes.filter(f => !f.applied).length;
2305
+ const previewResult = {
2306
+ success: true,
2307
+ persona: persona.name,
2308
+ status: "📋 Preview - Fixes Available",
2309
+ mode: "optimize",
2310
+ nodes: fixedWorkflow.actions?.length ?? 0,
2311
+ found_issues: issues.length,
2312
+ can_fix: fixedCount,
2313
+ cannot_fix: failedCount,
2314
+ ready_to_deploy: remainingCritical.length === 0,
2315
+ issues: issues.length > 0 ? issues.map(i => `${i.severity === "critical" ? "❌" : "⚠️"} ${i.type}: ${i.reason}`) : ["No issues found"],
2316
+ fixes: appliedFixes.length > 0 ? appliedFixes.map(f => `${f.applied ? "✅" : "❌"} ${f.description}`) : [],
2317
+ next_step: remainingCritical.length === 0
2318
+ ? `Run workflow(persona_id="${personaId}", mode="optimize") without preview to deploy fixes.`
2319
+ : `${remainingCritical.length} critical issue(s) need manual fix in Ema UI.`,
2320
+ };
2321
+ if (enhancementPrompt) {
2322
+ previewResult.enhancement_note = `Enhancement "${enhancementPrompt}" noted. Auto-fixes will be applied first; use Ema UI Auto Builder for the enhancement.`;
2323
+ }
2324
+ return previewResult;
2325
+ }
2326
+ // Step 4: Deploy if no critical issues remain
2327
+ if (remainingCritical.length > 0) {
2328
+ return {
2329
+ success: false,
2330
+ persona: persona.name,
2331
+ status: "❌ Cannot auto-fix",
2332
+ fixed: appliedFixes.filter(f => f.applied).length,
2333
+ remaining_critical: remainingCritical.length,
2334
+ manual_fixes_needed: remainingCritical.map(i => ({
2335
+ problem: i.type,
2336
+ details: i.reason,
2337
+ fix: i.recommendation,
2338
+ })),
2339
+ message: `${remainingCritical.length} issue(s) need manual fix in the Ema UI Auto Builder.`,
2340
+ };
2341
+ }
2342
+ // Sanitize workflow before deployment
2343
+ // Fix enumTypes - check nested structure
2344
+ const enumTypes = fixedWorkflow.enumTypes;
2345
+ if (Array.isArray(enumTypes)) {
2346
+ const validEnumTypes = enumTypes.filter(e => {
2347
+ const outerName = e.name;
2348
+ const innerName = outerName?.name;
2349
+ const actualName = innerName?.name;
2350
+ return typeof actualName === "string" && actualName.trim().length > 0;
2351
+ });
2352
+ if (validEnumTypes.length > 0) {
2353
+ fixedWorkflow.enumTypes = validEnumTypes;
2354
+ }
2355
+ else {
2356
+ delete fixedWorkflow.enumTypes;
2357
+ }
2358
+ }
2359
+ // Copy workflowName from existing
2360
+ const existingWfName = workflowDef.workflowName;
2361
+ if (existingWfName) {
2362
+ fixedWorkflow.workflowName = JSON.parse(JSON.stringify(existingWfName));
2363
+ }
2364
+ // Merge proto_config (sanitize widgets)
2365
+ const isValidWidget = (w) => {
2366
+ const name = w.name;
2367
+ return typeof name === "string" && name.trim().length > 0;
2368
+ };
2369
+ let mergedProtoConfig = persona.proto_config ?? {};
2370
+ if (mergedProtoConfig.widgets && Array.isArray(mergedProtoConfig.widgets)) {
2371
+ const sanitizedWidgets = mergedProtoConfig.widgets.filter(isValidWidget);
2372
+ mergedProtoConfig = { ...mergedProtoConfig, widgets: sanitizedWidgets };
2373
+ }
2374
+ // Build and send request
2375
+ const req = {
2376
+ persona_id: personaId,
2377
+ proto_config: mergedProtoConfig,
2378
+ workflow: fixedWorkflow,
2379
+ };
2380
+ const actionsArr = fixedWorkflow.actions ?? [];
2381
+ try {
2382
+ await client.updateAiEmployee(req);
2383
+ const fixedCount = appliedFixes.filter(f => f.applied).length;
2384
+ const deployResult = {
2385
+ success: true,
2386
+ persona: persona.name,
2387
+ status: "✅ Optimized & Deployed",
2388
+ mode: "optimized",
2389
+ issues_found: issues.length,
2390
+ issues_fixed: fixedCount,
2391
+ nodes: postFixAnalysis.summary?.total_nodes ?? actionsArr.length,
2392
+ message: fixedCount > 0
2393
+ ? `Fixed ${fixedCount} issue(s) and deployed!`
2394
+ : "Deployed successfully!",
2395
+ next_step: "Test in the Ema simulator to verify behavior.",
2396
+ };
2397
+ if (enhancementPrompt) {
2398
+ deployResult.enhancement_note = `Fixes applied. Enhancement "${enhancementPrompt}" requires manual implementation via Ema UI Auto Builder.`;
2399
+ }
2400
+ return deployResult;
2401
+ }
2402
+ catch (err) {
2403
+ const errorMessage = err instanceof Error ? err.message : String(err);
2404
+ // Extract EmaApiError body if available
2405
+ const apiBody = err?.body;
2406
+ const statusCode = err?.statusCode;
2407
+ return {
2408
+ success: false,
2409
+ persona: persona.name,
2410
+ status: "❌ Deploy Failed",
2411
+ error: errorMessage,
2412
+ status_code: statusCode,
2413
+ api_response: apiBody ? JSON.parse(apiBody) : undefined,
2414
+ fixes_attempted: appliedFixes.filter(f => f.applied).length,
2415
+ suggestion: "Check the api_response field for the actual backend error.",
2416
+ };
2417
+ }
2418
+ },
2419
+ // ─────────────────────────────────────────────────────────────────────────
2420
+ // Action Handlers (Consolidated)
2421
+ // ─────────────────────────────────────────────────────────────────────────
2422
+ get_workflow_action: async (args) => {
2423
+ const client = createClient(args.env);
2424
+ const identifier = String(args.identifier);
2425
+ const actions = await client.listActions();
2426
+ // Try ID first, then name
2427
+ const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(identifier);
2428
+ let action = isUUID
2429
+ ? actions.find((a) => a.id === identifier)
2430
+ : actions.find((a) => a.name?.toLowerCase() === identifier.toLowerCase());
2431
+ if (!action) {
2432
+ throw new Error(`Action not found: ${identifier} (searched by ${isUUID ? "ID" : "name"})`);
2433
+ }
2434
+ return { environment: client["env"].name, action };
2435
+ },
2436
+ find_workflow_actions: async (args) => {
2437
+ const client = createClient(args.env);
2438
+ // Handle list_categories request
2439
+ if (args.list_categories === true) {
2440
+ const actions = await client.listActions();
2441
+ const categoryMap = new Map();
2442
+ for (const a of actions) {
2443
+ const cat = a.category ?? "uncategorized";
2444
+ categoryMap.set(cat, (categoryMap.get(cat) ?? 0) + 1);
2445
+ }
2446
+ return {
2447
+ environment: client["env"].name,
2448
+ categories: Array.from(categoryMap.entries()).map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count),
2449
+ };
2450
+ }
2451
+ // Handle persona/workflow scope
2452
+ if (args.persona_id) {
2453
+ const personaId = String(args.persona_id);
2454
+ const personas = await client.getPersonasForTenant();
2455
+ const persona = personas.find((p) => p.id === personaId);
2456
+ if (!persona)
2457
+ throw new Error(`AI Employee not found: ${personaId}`);
2458
+ if (!persona.workflow_id) {
2459
+ return {
2460
+ environment: client["env"].name,
2461
+ persona_id: personaId,
2462
+ persona_name: persona.name,
2463
+ error: "AI Employee has no workflow",
2464
+ actions: [],
2465
+ };
2466
+ }
2467
+ const actions = await client.listActionsFromWorkflow(persona.workflow_id);
2468
+ return {
2469
+ environment: client["env"].name,
2470
+ persona_id: personaId,
2471
+ persona_name: persona.name,
2472
+ workflow_id: persona.workflow_id,
2473
+ count: actions.length,
2474
+ actions: actions.map((a) => ({
2475
+ id: a.id, name: a.name, description: a.description,
2476
+ category: a.category, inputs: a.inputs, outputs: a.outputs,
2477
+ })),
2478
+ };
2479
+ }
2480
+ if (args.workflow_id) {
2481
+ const workflowId = String(args.workflow_id);
2482
+ const actions = await client.listActionsFromWorkflow(workflowId);
2483
+ return {
2484
+ environment: client["env"].name,
2485
+ workflow_id: workflowId,
2486
+ count: actions.length,
2487
+ actions: actions.map((a) => ({
2488
+ id: a.id, name: a.name, description: a.description,
2489
+ category: a.category, inputs: a.inputs, outputs: a.outputs,
2490
+ })),
2491
+ };
2492
+ }
2493
+ // Default: search all actions
2494
+ let actions = await client.listActions();
2495
+ if (args.query) {
2496
+ const q = String(args.query).toLowerCase();
2497
+ actions = actions.filter((a) => a.name?.toLowerCase().includes(q));
2498
+ }
2499
+ if (args.category) {
2500
+ const f = String(args.category).toLowerCase();
2501
+ actions = actions.filter((a) => a.category?.toLowerCase() === f);
2502
+ }
2503
+ if (typeof args.enabled === "boolean") {
2504
+ actions = actions.filter((a) => a.enabled === args.enabled);
2505
+ }
2506
+ const limit = typeof args.limit === "number" ? args.limit : 100;
2507
+ actions = actions.slice(0, limit);
2508
+ return {
2509
+ environment: client["env"].name,
2510
+ count: actions.length,
2511
+ actions: actions.map((a) => ({
2512
+ id: a.id, name: a.name, description: a.description,
2513
+ category: a.category, enabled: a.enabled, tags: a.tags,
2514
+ })),
2515
+ };
2516
+ },
2517
+ // ─────────────────────────────────────────────────────────────────────────
2518
+ // ─────────────────────────────────────────────────────────────────────────
2519
+ // Diagnostics & Comparison
2520
+ // ─────────────────────────────────────────────────────────────────────────
2521
+ compare_ai_employees: async (args) => {
2522
+ const env1 = args.env_1 ?? getDefaultEnvName();
2523
+ const env2 = args.env_2 ?? env1;
2524
+ const id1 = String(args.persona_id_1);
2525
+ const id2 = String(args.persona_id_2);
2526
+ const client1 = createClient(env1);
2527
+ const client2 = env1 === env2 ? client1 : createClient(env2);
2528
+ const [p1, p2] = await Promise.all([
2529
+ client1.getPersonaById(id1),
2530
+ client2.getPersonaById(id2),
2531
+ ]);
2532
+ if (!p1)
2533
+ throw new Error(`AI Employee not found: ${id1} in ${env1}`);
2534
+ if (!p2)
2535
+ throw new Error(`AI Employee not found: ${id2} in ${env2}`);
2536
+ const fp1 = fingerprintPersona(p1);
2537
+ const fp2 = fingerprintPersona(p2);
2538
+ const compareFields = ["name", "description", "status", "trigger_type", "access_level", "embedding_enabled", "template_id", "workflow_id"];
2539
+ const differences = [];
2540
+ for (const field of compareFields) {
2541
+ const val1 = p1[field] ?? p1[field === "template_id" ? "templateId" : field];
2542
+ const val2 = p2[field] ?? p2[field === "template_id" ? "templateId" : field];
2543
+ if (JSON.stringify(val1) !== JSON.stringify(val2)) {
2544
+ differences.push({ field, value_1: val1, value_2: val2 });
2545
+ }
2546
+ }
2547
+ if (JSON.stringify(p1.proto_config ?? {}) !== JSON.stringify(p2.proto_config ?? {})) {
2548
+ differences.push({ field: "proto_config", value_1: "(differs)", value_2: "(differs)" });
2549
+ }
2550
+ if (JSON.stringify(p1.welcome_messages ?? {}) !== JSON.stringify(p2.welcome_messages ?? {})) {
2551
+ differences.push({ field: "welcome_messages", value_1: "(differs)", value_2: "(differs)" });
2552
+ }
2553
+ return {
2554
+ persona_1: { id: id1, env: env1, name: p1.name, fingerprint: fp1 },
2555
+ persona_2: { id: id2, env: env2, name: p2.name, fingerprint: fp2 },
2556
+ fingerprints_match: fp1 === fp2,
2557
+ difference_count: differences.length,
2558
+ differences,
2559
+ };
2560
+ },
2561
+ list_ai_employee_templates: async (args) => {
2562
+ const client = createClient(args.env);
2563
+ const personas = await client.getPersonasForTenant();
2564
+ const templateMap = new Map();
2565
+ for (const p of personas) {
2566
+ const templateId = p.template_id ?? p.templateId ?? "unknown";
2567
+ const existing = templateMap.get(templateId) ?? { count: 0, names: [] };
2568
+ existing.count++;
2569
+ if (p.name && existing.names.length < 3)
2570
+ existing.names.push(p.name);
2571
+ templateMap.set(templateId, existing);
2572
+ }
2573
+ const templates = Array.from(templateMap.entries())
2574
+ .map(([template_id, data]) => ({ template_id, usage_count: data.count, examples: data.names }))
2575
+ .sort((a, b) => b.usage_count - a.usage_count);
2576
+ return {
2577
+ environment: client["env"].name,
2578
+ total_ai_employees: personas.length,
2579
+ template_count: templates.length,
2580
+ templates,
2581
+ };
2582
+ },
2583
+ // ─────────────────────────────────────────────────────────────────────────
2584
+ // CONSOLIDATED SYNC HANDLERS
2585
+ // ─────────────────────────────────────────────────────────────────────────
2586
+ sync: async (args) => {
2587
+ const targetEnv = String(args.target_env);
2588
+ const sourceEnv = args.source_env ? String(args.source_env) : getDefaultEnvName();
2589
+ const dryRun = args.dry_run === true;
2590
+ const includeStatus = args.include_status === true;
2591
+ const scope = args.scope === "all" ? "all" : "one";
2592
+ const identifier = args.identifier ? String(args.identifier) : undefined;
2593
+ // Sync all tagged personas
2594
+ if (scope === "all" || !identifier) {
2595
+ const sdk = getSyncSDK();
2596
+ if (sdk) {
2597
+ try {
2598
+ const result = await sdk.runSync();
2599
+ return { success: true, mode: "config", ...result };
2600
+ }
2601
+ finally {
2602
+ sdk.close();
2603
+ }
2604
+ }
2605
+ // Config-less mode
2606
+ try {
2607
+ const result = await directSyncAll({ targetEnv, dryRun });
2608
+ return { success: true, mode: "tags", ...result };
2609
+ }
2610
+ catch (e) {
2611
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
2612
+ }
2613
+ }
2614
+ // Sync single persona
2615
+ const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(identifier);
2616
+ const behavior = resolveSyncBehavior({
2617
+ personaName: isUUID ? undefined : identifier,
2618
+ targetEnv,
2619
+ overrides: {
2620
+ dry_run: dryRun ? true : undefined,
2621
+ sync_status: includeStatus ? true : undefined,
2622
+ },
2623
+ });
2624
+ try {
2625
+ const result = isUUID
2626
+ ? await directSyncPersonaById({
2627
+ personaId: identifier,
2628
+ sourceEnv,
2629
+ targetEnv,
2630
+ dryRun: behavior.dry_run,
2631
+ syncStatus: behavior.sync_status,
2632
+ })
2633
+ : await directSyncPersona({
2634
+ name: identifier,
2635
+ sourceEnv,
2636
+ targetEnv,
2637
+ dryRun: behavior.dry_run,
2638
+ syncStatus: behavior.sync_status,
2639
+ });
2640
+ return { ...result, resolved_behavior: behavior };
2641
+ }
2642
+ catch (e) {
2643
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
2644
+ }
2645
+ },
2646
+ sync_info: async (args) => {
2647
+ const client = args.env ? createClient(args.env) : undefined;
2648
+ // Check if persona is synced
2649
+ if (args.persona_id) {
2650
+ if (!client)
2651
+ throw new Error("env required when checking persona sync status");
2652
+ const personaId = String(args.persona_id);
2653
+ const personas = await client.getPersonasForTenant();
2654
+ const persona = personas.find((p) => p.id === personaId);
2655
+ if (!persona)
2656
+ throw new Error(`AI Employee not found: ${personaId}`);
2657
+ const meta = client.getSyncMetadata(persona);
2658
+ return {
2659
+ environment: client["env"].name,
2660
+ persona_id: personaId,
2661
+ persona_name: persona.name,
2662
+ is_synced: !!meta,
2663
+ sync_metadata: meta,
2664
+ };
2665
+ }
2666
+ // Check by persona name
2667
+ if (args.persona_name) {
2668
+ const sdk = getSyncSDK();
2669
+ if (!sdk)
2670
+ return { error: "No sync config found. Set EMA_AGENT_SYNC_CONFIG." };
2671
+ try {
2672
+ const persona = await sdk.getMasterPersonaByName(String(args.persona_name));
2673
+ if (!persona)
2674
+ return { error: `Persona not found: ${args.persona_name}` };
2675
+ return await sdk.getPersonaSyncStatus(persona.id);
2676
+ }
2677
+ finally {
2678
+ sdk.close();
2679
+ }
2680
+ }
2681
+ // List all synced personas
2682
+ if (args.list_synced === true) {
2683
+ if (!client)
2684
+ throw new Error("env required when listing synced personas");
2685
+ const personas = await client.getPersonasForTenant();
2686
+ const masterEnvFilter = args.master_env ? String(args.master_env).toLowerCase() : undefined;
2687
+ const synced = [];
2688
+ for (const p of personas) {
2689
+ const meta = client.getSyncMetadata(p);
2690
+ if (meta) {
2691
+ if (masterEnvFilter && meta.master_env.toLowerCase() !== masterEnvFilter)
2692
+ continue;
2693
+ synced.push({ persona_id: p.id, persona_name: p.name, sync_metadata: meta });
2694
+ }
2695
+ }
2696
+ return { environment: client["env"].name, count: synced.length, synced_personas: synced };
2697
+ }
2698
+ // Default: return overall sync config/status
2699
+ const sdk = getSyncSDK();
2700
+ const options = args.include_options === true ? loadSyncOptions() : undefined;
2701
+ if (!sdk) {
2702
+ return {
2703
+ configured: false,
2704
+ error: "No sync config found. Set EMA_AGENT_SYNC_CONFIG.",
2705
+ options,
2706
+ };
2707
+ }
2708
+ try {
2709
+ const master = sdk.getMasterEnvironment();
2710
+ const envs = sdk.getEnvironments();
2711
+ const personas = await sdk.listMasterPersonas();
2712
+ return {
2713
+ configured: true,
2714
+ master_environment: { name: master.name, url: master.baseUrl },
2715
+ target_environments: envs.filter((e) => !e.isMaster).map((e) => ({ name: e.name, url: e.baseUrl })),
2716
+ total_personas: personas.length,
2717
+ options,
2718
+ };
2719
+ }
2720
+ finally {
2721
+ sdk.close();
2722
+ }
2723
+ },
2724
+ // ─────────────────────────────────────────────────────────────────────────
2725
+ // ─────────────────────────────────────────────────────────────────────────────
2726
+ // Auto Builder Knowledge Handlers
2727
+ // ─────────────────────────────────────────────────────────────────────────────
2728
+ list_auto_builder_agents: async (args) => {
2729
+ const category = args.category;
2730
+ const agents = category ? getAgentsByCategory(category) : AGENT_CATALOG;
2731
+ return {
2732
+ count: agents.length,
2733
+ category: category ?? "all",
2734
+ agents: agents.map(a => ({
2735
+ action_name: a.actionName,
2736
+ display_name: a.displayName,
2737
+ category: a.category,
2738
+ description: a.description,
2739
+ when_to_use: a.whenToUse,
2740
+ inputs: a.inputs.map(i => i.name),
2741
+ outputs: a.outputs.map(o => o.name),
2742
+ has_critical_rules: !!a.criticalRules?.length,
2743
+ })),
2744
+ };
2745
+ },
2746
+ get_auto_builder_agent: async (args) => {
2747
+ const actionName = String(args.action_name);
2748
+ const agent = getAgentByName(actionName);
2749
+ if (!agent) {
2750
+ const available = AGENT_CATALOG.slice(0, 15).map(a => a.actionName);
2751
+ return {
2752
+ error: `Agent not found: ${actionName}`,
2753
+ available_examples: available,
2754
+ hint: "Use list_auto_builder_agents to see all available agents",
2755
+ };
2756
+ }
2757
+ return {
2758
+ action_name: agent.actionName,
2759
+ display_name: agent.displayName,
2760
+ category: agent.category,
2761
+ description: agent.description,
2762
+ inputs: agent.inputs,
2763
+ outputs: agent.outputs,
2764
+ critical_rules: agent.criticalRules ?? [],
2765
+ when_to_use: agent.whenToUse,
2766
+ when_not_to_use: agent.whenNotToUse,
2767
+ example: agent.example,
2768
+ };
2769
+ },
2770
+ suggest_agents_for_use_case: async (args) => {
2771
+ const useCase = String(args.use_case);
2772
+ const suggestions = suggestAgentsForUseCase(useCase);
2773
+ return {
2774
+ use_case: useCase,
2775
+ suggested_agent_count: suggestions.length,
2776
+ suggested_agents: suggestions.map(a => ({
2777
+ action_name: a.actionName,
2778
+ display_name: a.displayName,
2779
+ category: a.category,
2780
+ why: a.whenToUse,
2781
+ inputs: a.inputs.map(i => `${i.name} (${i.type})`),
2782
+ outputs: a.outputs.map(o => `${o.name} (${o.type})`),
2783
+ })),
2784
+ suggested_flow: suggestions.map(a => a.actionName).join(" → "),
2785
+ next_steps: [
2786
+ "Use get_auto_builder_agent to get detailed info on each agent",
2787
+ "Use get_workflow_pattern for a complete template if a pattern matches",
2788
+ "Use get_qualifying_questions to ensure you have all required information",
2789
+ ],
2790
+ };
2791
+ },
2792
+ get_workflow_pattern: async (args) => {
2793
+ const patternName = String(args.pattern_name);
2794
+ const pattern = WORKFLOW_PATTERNS.find(p => p.name === patternName);
2795
+ if (!pattern) {
2796
+ return {
2797
+ error: `Pattern not found: ${patternName}`,
2798
+ available: WORKFLOW_PATTERNS.map(p => ({ name: p.name, description: p.description })),
2799
+ };
2800
+ }
2801
+ return {
2802
+ name: pattern.name,
2803
+ persona_type: pattern.personaType,
2804
+ description: pattern.description,
2805
+ use_case: pattern.useCase,
2806
+ nodes: pattern.nodes,
2807
+ connections: pattern.connections,
2808
+ anti_patterns: pattern.antiPatterns ?? [],
2809
+ implementation_notes: [
2810
+ "Replace * with actual category/handler names",
2811
+ "All paths must lead to WORKFLOW_OUTPUT",
2812
+ "Include Fallback category for categorizers",
2813
+ "Check type compatibility for all connections",
2814
+ ],
2815
+ };
2816
+ },
2817
+ list_workflow_patterns: async (args) => {
2818
+ const personaType = args.persona_type;
2819
+ const patterns = personaType
2820
+ ? WORKFLOW_PATTERNS.filter(p => p.personaType === personaType)
2821
+ : WORKFLOW_PATTERNS;
2822
+ return {
2823
+ count: patterns.length,
2824
+ persona_type_filter: personaType ?? "all",
2825
+ patterns: patterns.map(p => ({
2826
+ name: p.name,
2827
+ persona_type: p.personaType,
2828
+ description: p.description,
2829
+ use_case: p.useCase,
2830
+ node_count: p.nodes.length,
2831
+ })),
2832
+ };
2833
+ },
2834
+ check_type_compatibility: async (args) => {
2835
+ const sourceType = String(args.source_type);
2836
+ const targetType = String(args.target_type);
2837
+ const compat = checkTypeCompatibility(sourceType, targetType);
2838
+ if (!compat) {
2839
+ return {
2840
+ source_type: sourceType,
2841
+ target_type: targetType,
2842
+ compatible: false,
2843
+ note: "No explicit compatibility rule found - likely incompatible",
2844
+ recommendation: "Use an intermediate node to convert types, or check if target accepts WELL_KNOWN_TYPE_ANY",
2845
+ };
2846
+ }
2847
+ return {
2848
+ source_type: sourceType,
2849
+ target_type: targetType,
2850
+ compatible: compat.compatible,
2851
+ note: compat.note,
2852
+ recommendation: compat.compatible
2853
+ ? "These types are compatible for direct connection"
2854
+ : `Incompatible. ${compat.note || "Use an intermediate node to convert types."}`,
2855
+ };
2856
+ },
2857
+ get_widget_reference: async (args) => {
2858
+ const personaType = String(args.persona_type);
2859
+ const widgets = getWidgetsForPersonaType(personaType);
2860
+ const projectType = PROJECT_TYPES[personaType];
2861
+ return {
2862
+ persona_type: personaType,
2863
+ project_type: projectType,
2864
+ widget_count: widgets.length,
2865
+ widgets: widgets.map(w => ({
2866
+ id: w.id,
2867
+ name: w.name,
2868
+ description: w.description,
2869
+ fields: w.fields,
2870
+ })),
2871
+ note: `Project type ${projectType} is used in proto_config for ${personaType} AI Employees`,
2872
+ };
2873
+ },
2874
+ get_qualifying_questions: async (args) => {
2875
+ const category = args.category;
2876
+ const requiredOnly = args.required_only === true;
2877
+ let questions = category
2878
+ ? getQualifyingQuestionsByCategory(category)
2879
+ : QUALIFYING_QUESTIONS;
2880
+ if (requiredOnly) {
2881
+ questions = questions.filter(q => q.required);
2882
+ }
2883
+ const grouped = questions.reduce((acc, q) => {
2884
+ if (!acc[q.category])
2885
+ acc[q.category] = [];
2886
+ acc[q.category].push({ question: q.question, why_it_matters: q.whyItMatters, required: q.required });
2887
+ return acc;
2888
+ }, {});
2889
+ return {
2890
+ total_questions: questions.length,
2891
+ categories: Object.keys(grouped),
2892
+ questions_by_category: grouped,
2893
+ minimum_required: [
2894
+ "AI Type (Voice/Chat/Dashboard)",
2895
+ "2-3 intent categories + Fallback",
2896
+ "1 primary data source or action",
2897
+ "Success output format",
2898
+ ],
2899
+ questioning_rounds: {
2900
+ round_1: "Core Context: AI type, trigger, main intents, data sources",
2901
+ round_2: "Workflow Details: Actions, validations, outputs, approvals",
2902
+ round_3: "Voice/Chat Specifics: Welcome message, hangup conditions (if applicable)",
2903
+ },
2904
+ };
2905
+ },
2906
+ get_voice_persona_template: async () => {
2907
+ return {
2908
+ template: VOICE_PERSONA_TEMPLATE,
2909
+ required_fields: ["welcomeMessage", "identityAndPurpose", "takeActionInstructions", "hangupInstructions"],
2910
+ optional_fields: ["transferCallInstructions", "speechCharacteristics", "systemPrompt", "formFillingInstructions", "waitMessage"],
2911
+ project_type: PROJECT_TYPES.voice,
2912
+ widget_ids: {
2913
+ voiceSettings: 38,
2914
+ conversationSettings: 39,
2915
+ vadSettings: 43,
2916
+ dataStorageSettings: 42,
2917
+ },
2918
+ instructions: [
2919
+ "Replace all {placeholder} values with actual content",
2920
+ "takeActionInstructions uses </Case N> format for each action",
2921
+ "speechCharacteristics should include TTS pronunciation rules",
2922
+ "systemPrompt defines tool calling behavior",
2923
+ ],
2924
+ };
2925
+ },
2926
+ validate_workflow_prompt: async (args) => {
2927
+ const prompt = String(args.prompt);
2928
+ const result = validateWorkflowPrompt(prompt);
2929
+ return {
2930
+ valid: result.valid,
2931
+ issue_count: result.issues.length,
2932
+ warning_count: result.warnings.length,
2933
+ issues: result.issues,
2934
+ warnings: result.warnings,
2935
+ recommendations: result.issues.length > 0 ? [
2936
+ "Add Fallback category to all categorizers",
2937
+ "Ensure HITL nodes have both success and failure paths",
2938
+ "Map all response nodes to WORKFLOW_OUTPUT",
2939
+ "Specify persona type (Voice AI, Chat AI, Dashboard AI)",
2940
+ "Check type compatibility for all connections",
2941
+ ] : ["Prompt structure looks valid - verify type compatibility after generation"],
2942
+ };
2943
+ },
2944
+ get_auto_builder_guidance: async (args) => {
2945
+ const topic = String(args.topic);
2946
+ const guidance = GUIDANCE_TOPICS[topic];
2947
+ if (!guidance) {
2948
+ return {
2949
+ error: `Topic not found: ${topic}`,
2950
+ available_topics: Object.keys(GUIDANCE_TOPICS).map(k => ({
2951
+ topic: k,
2952
+ title: GUIDANCE_TOPICS[k].title,
2953
+ })),
2954
+ };
2955
+ }
2956
+ return guidance;
2957
+ },
2958
+ get_platform_concept: async (args) => {
2959
+ const term = String(args.term);
2960
+ const concept = getConceptByTerm(term);
2961
+ if (!concept) {
2962
+ return {
2963
+ error: `Concept not found: ${term}`,
2964
+ available_concepts: PLATFORM_CONCEPTS.map(c => c.term),
2965
+ hint: "Try searching for aliases like 'Persona' (AI Employee) or 'Action' (Agent)",
2966
+ };
2967
+ }
2968
+ return {
2969
+ term: concept.term,
2970
+ definition: concept.definition,
2971
+ aliases: concept.aliases ?? [],
2972
+ related_terms: concept.relatedTerms ?? [],
2973
+ examples: concept.examples ?? [],
2974
+ common_confusions: concept.commonConfusions,
2975
+ };
2976
+ },
2977
+ list_platform_concepts: async () => {
2978
+ return {
2979
+ count: PLATFORM_CONCEPTS.length,
2980
+ concepts: PLATFORM_CONCEPTS.map(c => ({
2981
+ term: c.term,
2982
+ definition: c.definition,
2983
+ aliases: c.aliases ?? [],
2984
+ })),
2985
+ key_relationships: [
2986
+ "AI Employee CONTAINS Workflow (processing logic) + Persona (conversational behavior)",
2987
+ "Workflow is made up of Agents/Actions connected by Edges",
2988
+ "Agents use Connectors to interact with external systems",
2989
+ "HITL = Human-in-the-Loop approval/verification step",
2990
+ ],
2991
+ };
2992
+ },
2993
+ get_common_mistakes: async () => {
2994
+ return {
2995
+ count: COMMON_MISTAKES.length,
2996
+ mistakes: COMMON_MISTAKES,
2997
+ top_3_critical: [
2998
+ COMMON_MISTAKES.find(m => m.mistake.includes("Fallback")),
2999
+ COMMON_MISTAKES.find(m => m.mistake.includes("HITL")),
3000
+ COMMON_MISTAKES.find(m => m.mistake.includes("duplicate")),
3001
+ ].filter(Boolean),
3002
+ };
3003
+ },
3004
+ get_debug_checklist: async () => {
3005
+ return {
3006
+ total_steps: DEBUG_CHECKLIST.length,
3007
+ checklist: DEBUG_CHECKLIST,
3008
+ quick_checks: [
3009
+ "Is the AI Employee status 'active' or 'ready'?",
3010
+ "Does the categorizer have all category edges including Fallback?",
3011
+ "Do all paths lead to WORKFLOW_OUTPUT?",
3012
+ "Are all connections type-compatible?",
3013
+ ],
3014
+ };
3015
+ },
3016
+ get_workflow_execution_model: async () => {
3017
+ return {
3018
+ ...WORKFLOW_EXECUTION_MODEL,
3019
+ summary: "Each user message triggers a NEW workflow execution. Use chat_conversation to detect previous actions and avoid duplicates.",
3020
+ };
3021
+ },
3022
+ // ─────────────────────────────────────────────────────────────────────────────
3023
+ // Workflow Review & Audit Handlers
3024
+ // ─────────────────────────────────────────────────────────────────────────────
3025
+ analyze_workflow: async (args) => {
3026
+ const client = createClient(args.env);
3027
+ const personaId = String(args.persona_id);
3028
+ const persona = await client.getPersonaById(personaId);
3029
+ if (!persona)
3030
+ throw new Error(`AI Employee not found: ${personaId}`);
3031
+ if (!persona.workflow_def) {
3032
+ return {
3033
+ environment: client["env"].name,
3034
+ persona_id: personaId,
3035
+ persona_name: persona.name,
3036
+ error: "AI Employee has no workflow_def",
3037
+ recommendation: "This persona may not have a generated workflow. Use Auto Builder to create one.",
3038
+ };
3039
+ }
3040
+ const analysis = analyzeWorkflow(persona.workflow_def, {
3041
+ persona_id: personaId,
3042
+ persona_name: persona.name,
3043
+ environment: client["env"].name,
3044
+ });
3045
+ return {
3046
+ ...analysis,
3047
+ recommendations: analysis.validation_passed
3048
+ ? ["Workflow structure looks valid. Test with sample queries to verify behavior."]
3049
+ : [
3050
+ "Fix all critical issues before deployment",
3051
+ ...analysis.issues
3052
+ .filter(i => i.severity === "critical")
3053
+ .slice(0, 3)
3054
+ .map(i => `Priority: ${i.reason}`),
3055
+ "Use suggest_workflow_fixes to get fix proposals",
3056
+ ],
3057
+ };
3058
+ },
3059
+ detect_workflow_issues: async (args) => {
3060
+ const workflowDef = args.workflow_def;
3061
+ if (!workflowDef || typeof workflowDef !== "object") {
3062
+ return {
3063
+ error: "Invalid workflow_def - must be an object",
3064
+ hint: "Get workflow_def from get_ai_employee_full(persona_id).ai_employee.workflow_def",
3065
+ };
3066
+ }
3067
+ const issues = detectWorkflowIssues(workflowDef);
3068
+ const summary = {
3069
+ total: issues.length,
3070
+ critical: issues.filter(i => i.severity === "critical").length,
3071
+ warning: issues.filter(i => i.severity === "warning").length,
3072
+ info: issues.filter(i => i.severity === "info").length,
3073
+ };
3074
+ return {
3075
+ issues,
3076
+ summary,
3077
+ validation_passed: summary.critical === 0,
3078
+ issue_types_found: [...new Set(issues.map(i => i.type))],
3079
+ next_steps: issues.length > 0
3080
+ ? ["Use suggest_workflow_fixes(issues) to get fix proposals"]
3081
+ : ["No issues detected - workflow structure looks valid"],
3082
+ };
3083
+ },
3084
+ validate_workflow_connections: async (args) => {
3085
+ const workflowDef = args.workflow_def;
3086
+ if (!workflowDef || typeof workflowDef !== "object") {
3087
+ return {
3088
+ error: "Invalid workflow_def - must be an object",
3089
+ hint: "Get workflow_def from get_ai_employee_full(persona_id).ai_employee.workflow_def",
3090
+ };
3091
+ }
3092
+ const validations = validateWorkflowConnections(workflowDef);
3093
+ const compatible = validations.filter(v => v.compatible);
3094
+ const incompatible = validations.filter(v => !v.compatible);
3095
+ return {
3096
+ total_edges: validations.length,
3097
+ compatible_count: compatible.length,
3098
+ incompatible_count: incompatible.length,
3099
+ all_valid: incompatible.length === 0,
3100
+ validations: validations.map(v => ({
3101
+ edge: v.edge_id,
3102
+ source_type: v.source_type,
3103
+ target_type: v.target_type,
3104
+ compatible: v.compatible,
3105
+ note: v.note,
3106
+ })),
3107
+ incompatible_edges: incompatible.map(v => ({
3108
+ edge: v.edge_id,
3109
+ source_type: v.source_type,
3110
+ target_type: v.target_type,
3111
+ note: v.note,
3112
+ fix_hint: v.source_type === "WELL_KNOWN_TYPE_CHAT_CONVERSATION" && v.target_type === "WELL_KNOWN_TYPE_TEXT_WITH_SOURCES"
3113
+ ? "Insert conversation_to_search_query between source and target"
3114
+ : "Use named_inputs (accepts ANY type) or add intermediate conversion node",
3115
+ })),
3116
+ };
3117
+ },
3118
+ suggest_workflow_fixes: async (args) => {
3119
+ const issues = args.issues;
3120
+ if (!Array.isArray(issues)) {
3121
+ return {
3122
+ error: "Invalid issues array",
3123
+ hint: "Pass issues from detect_workflow_issues or analyze_workflow",
3124
+ example_input: [
3125
+ { type: "missing_fallback", severity: "critical", node: "intent_classifier", reason: "..." },
3126
+ { type: "type_mismatch", severity: "critical", source: "trigger.chat_conversation", target: "search.query", reason: "..." },
3127
+ ],
3128
+ };
3129
+ }
3130
+ if (issues.length === 0) {
3131
+ return {
3132
+ message: "No issues to fix",
3133
+ fixes: [],
3134
+ };
3135
+ }
3136
+ const fixes = suggestWorkflowFixes(issues);
3137
+ return {
3138
+ issue_count: issues.length,
3139
+ fix_count: fixes.length,
3140
+ fixes,
3141
+ application_order: [
3142
+ "1. Fix critical issues first (missing_fallback, incomplete_hitl, cycles)",
3143
+ "2. Fix type mismatches (may require adding intermediate nodes)",
3144
+ "3. Address warnings (wrong_input_source, orphan nodes)",
3145
+ "4. Re-run detect_workflow_issues to verify fixes",
3146
+ ],
3147
+ };
3148
+ },
3149
+ compare_workflow_versions: async (args) => {
3150
+ const client = createClient(args.env);
3151
+ const idBefore = String(args.persona_id_before);
3152
+ const idAfter = String(args.persona_id_after);
3153
+ const [personaBefore, personaAfter] = await Promise.all([
3154
+ client.getPersonaById(idBefore),
3155
+ client.getPersonaById(idAfter),
3156
+ ]);
3157
+ if (!personaBefore)
3158
+ throw new Error(`AI Employee not found (before): ${idBefore}`);
3159
+ if (!personaAfter)
3160
+ throw new Error(`AI Employee not found (after): ${idAfter}`);
3161
+ const analysisBefore = personaBefore.workflow_def
3162
+ ? analyzeWorkflow(personaBefore.workflow_def, { persona_id: idBefore, persona_name: personaBefore.name })
3163
+ : null;
3164
+ const analysisAfter = personaAfter.workflow_def
3165
+ ? analyzeWorkflow(personaAfter.workflow_def, { persona_id: idAfter, persona_name: personaAfter.name })
3166
+ : null;
3167
+ // Compare fingerprints
3168
+ const fpBefore = personaBefore.workflow_def ? fingerprintPersona(personaBefore) : null;
3169
+ const fpAfter = personaAfter.workflow_def ? fingerprintPersona(personaAfter) : null;
3170
+ return {
3171
+ environment: client["env"].name,
3172
+ before: {
3173
+ persona_id: idBefore,
3174
+ name: personaBefore.name,
3175
+ fingerprint: fpBefore,
3176
+ has_workflow: !!personaBefore.workflow_def,
3177
+ node_count: analysisBefore?.summary.total_nodes ?? 0,
3178
+ edge_count: analysisBefore?.summary.total_edges ?? 0,
3179
+ critical_issues: analysisBefore?.issue_summary.critical ?? 0,
3180
+ validation_passed: analysisBefore?.validation_passed ?? false,
3181
+ },
3182
+ after: {
3183
+ persona_id: idAfter,
3184
+ name: personaAfter.name,
3185
+ fingerprint: fpAfter,
3186
+ has_workflow: !!personaAfter.workflow_def,
3187
+ node_count: analysisAfter?.summary.total_nodes ?? 0,
3188
+ edge_count: analysisAfter?.summary.total_edges ?? 0,
3189
+ critical_issues: analysisAfter?.issue_summary.critical ?? 0,
3190
+ validation_passed: analysisAfter?.validation_passed ?? false,
3191
+ },
3192
+ comparison: {
3193
+ fingerprints_match: fpBefore === fpAfter,
3194
+ node_count_change: (analysisAfter?.summary.total_nodes ?? 0) - (analysisBefore?.summary.total_nodes ?? 0),
3195
+ edge_count_change: (analysisAfter?.summary.total_edges ?? 0) - (analysisBefore?.summary.total_edges ?? 0),
3196
+ critical_issues_change: (analysisAfter?.issue_summary.critical ?? 0) - (analysisBefore?.issue_summary.critical ?? 0),
3197
+ validation_improved: !analysisBefore?.validation_passed && (analysisAfter?.validation_passed ?? false),
3198
+ validation_regressed: (analysisBefore?.validation_passed ?? false) && !analysisAfter?.validation_passed,
3199
+ },
3200
+ issues_before: analysisBefore?.issues.slice(0, 5) ?? [],
3201
+ issues_after: analysisAfter?.issues.slice(0, 5) ?? [],
3202
+ recommendations: fpBefore === fpAfter
3203
+ ? ["No workflow changes detected between versions"]
3204
+ : [
3205
+ analysisAfter?.validation_passed
3206
+ ? "After version passes validation"
3207
+ : "After version has validation issues - review before deploying",
3208
+ (analysisAfter?.issue_summary.critical ?? 0) > (analysisBefore?.issue_summary.critical ?? 0)
3209
+ ? "Warning: More critical issues in after version"
3210
+ : (analysisAfter?.issue_summary.critical ?? 0) < (analysisBefore?.issue_summary.critical ?? 0)
3211
+ ? "Good: Fewer critical issues in after version"
3212
+ : "Same number of critical issues",
3213
+ ],
3214
+ };
3215
+ },
3216
+ get_workflow_metrics: async (args) => {
3217
+ const client = createClient(args.env);
3218
+ const personaId = String(args.persona_id);
3219
+ const persona = await client.getPersonaById(personaId);
3220
+ if (!persona)
3221
+ throw new Error(`AI Employee not found: ${personaId}`);
3222
+ if (!persona.workflow_def) {
3223
+ return {
3224
+ environment: client["env"].name,
3225
+ persona_id: personaId,
3226
+ persona_name: persona.name,
3227
+ error: "AI Employee has no workflow_def",
3228
+ };
3229
+ }
3230
+ const analysis = analyzeWorkflow(persona.workflow_def, {
3231
+ persona_id: personaId,
3232
+ persona_name: persona.name,
3233
+ environment: client["env"].name,
3234
+ });
3235
+ // Calculate complexity indicators
3236
+ const avgEdgesPerNode = analysis.summary.total_nodes > 0
3237
+ ? (analysis.summary.total_edges / analysis.summary.total_nodes).toFixed(2)
3238
+ : 0;
3239
+ // Estimate parallel vs sequential
3240
+ const hasParallelBranches = analysis.summary.categorizers_count > 0;
3241
+ return {
3242
+ environment: client["env"].name,
3243
+ persona_id: personaId,
3244
+ persona_name: persona.name,
3245
+ structure: {
3246
+ total_nodes: analysis.summary.total_nodes,
3247
+ total_edges: analysis.summary.total_edges,
3248
+ has_trigger: analysis.summary.has_trigger,
3249
+ has_workflow_output: analysis.summary.has_workflow_output,
3250
+ },
3251
+ routing: {
3252
+ categorizers_count: analysis.summary.categorizers_count,
3253
+ hitl_nodes_count: analysis.summary.hitl_nodes_count,
3254
+ has_parallel_branches: hasParallelBranches,
3255
+ },
3256
+ quality: {
3257
+ validation_passed: analysis.validation_passed,
3258
+ critical_issues: analysis.issue_summary.critical,
3259
+ warnings: analysis.issue_summary.warning,
3260
+ },
3261
+ complexity: {
3262
+ avg_edges_per_node: avgEdgesPerNode,
3263
+ complexity_rating: analysis.summary.total_nodes <= 5 ? "simple"
3264
+ : analysis.summary.total_nodes <= 15 ? "moderate"
3265
+ : "complex",
3266
+ },
3267
+ recommendations: [
3268
+ analysis.summary.categorizers_count === 0 && analysis.summary.total_nodes > 3
3269
+ ? "Consider adding intent routing with chat_categorizer for better user experience"
3270
+ : null,
3271
+ analysis.summary.hitl_nodes_count === 0
3272
+ ? "No HITL nodes - consider adding human approval for sensitive actions"
3273
+ : null,
3274
+ analysis.issue_summary.critical > 0
3275
+ ? `${analysis.issue_summary.critical} critical issues need attention - use analyze_workflow for details`
3276
+ : null,
3277
+ ].filter(Boolean),
3278
+ };
3279
+ },
3280
+ // ─────────────────────────────────────────────────────────────────────────
3281
+ // Workflow Compilation (Template-driven)
3282
+ // ─────────────────────────────────────────────────────────────────────────
3283
+ compile_workflow: async (args) => {
3284
+ const name = String(args.name);
3285
+ const description = String(args.description);
3286
+ const personaType = String(args.persona_type);
3287
+ const rawNodes = args.nodes;
3288
+ const rawResultMappings = args.result_mappings;
3289
+ if (!["voice", "chat", "dashboard"].includes(personaType)) {
3290
+ throw new Error(`Invalid persona_type: ${personaType}. Must be one of: voice, chat, dashboard`);
3291
+ }
3292
+ if (!rawNodes || rawNodes.length === 0) {
3293
+ throw new Error("At least one node is required");
3294
+ }
3295
+ if (!rawResultMappings || rawResultMappings.length === 0) {
3296
+ throw new Error("At least one result_mapping is required to connect outputs to WORKFLOW_OUTPUT");
3297
+ }
3298
+ // Convert raw input spec to internal Node format
3299
+ const nodes = rawNodes.map((rawNode) => {
3300
+ const node = {
3301
+ id: rawNode.id,
3302
+ actionType: rawNode.action_type,
3303
+ displayName: rawNode.display_name,
3304
+ description: rawNode.description,
3305
+ disableHitl: rawNode.disable_hitl,
3306
+ };
3307
+ // Convert inputs
3308
+ if (rawNode.inputs) {
3309
+ node.inputs = {};
3310
+ for (const [key, rawBinding] of Object.entries(rawNode.inputs)) {
3311
+ const binding = {
3312
+ type: rawBinding.type,
3313
+ actionName: rawBinding.action_name,
3314
+ output: rawBinding.output,
3315
+ value: rawBinding.value,
3316
+ widgetName: rawBinding.widget_name,
3317
+ };
3318
+ node.inputs[key] = binding;
3319
+ }
3320
+ }
3321
+ // Convert run_if condition
3322
+ if (rawNode.run_if) {
3323
+ node.runIf = {
3324
+ sourceAction: rawNode.run_if.source_action,
3325
+ sourceOutput: rawNode.run_if.source_output,
3326
+ operator: rawNode.run_if.operator,
3327
+ value: rawNode.run_if.value,
3328
+ };
3329
+ }
3330
+ // Convert categories
3331
+ if (rawNode.categories) {
3332
+ node.categories = rawNode.categories.map((cat) => ({
3333
+ name: cat.name,
3334
+ description: cat.description,
3335
+ examples: cat.examples,
3336
+ }));
3337
+ }
3338
+ // Convert tools
3339
+ if (rawNode.tools) {
3340
+ node.tools = rawNode.tools.map((tool) => ({
3341
+ name: tool.name,
3342
+ namespace: tool.namespace,
3343
+ }));
3344
+ }
3345
+ return node;
3346
+ });
3347
+ // Convert result mappings
3348
+ const resultMappings = rawResultMappings.map((rm) => ({
3349
+ nodeId: rm.node_id,
3350
+ output: rm.output,
3351
+ }));
3352
+ // Build the WorkflowSpec
3353
+ const spec = {
3354
+ name,
3355
+ description,
3356
+ personaType,
3357
+ nodes,
3358
+ resultMappings,
3359
+ };
3360
+ // Compile the workflow
3361
+ const result = compileWorkflow(spec);
3362
+ return {
3363
+ success: true,
3364
+ message: `Compiled workflow for ${personaType} AI Employee "${name}" with ${nodes.length} nodes`,
3365
+ workflow_def: result.workflow_def,
3366
+ proto_config: result.proto_config,
3367
+ nodes_compiled: nodes.map((n) => ({ id: n.id, action: n.actionType, display_name: n.displayName })),
3368
+ result_mappings: resultMappings,
3369
+ usage: 'Deploy with: workflow(mode="deploy", persona_id="<persona_id>", workflow_def=<workflow_def>, proto_config=<proto_config>)',
3370
+ };
3371
+ },
3372
+ // ─────────────────────────────────────────────────────────────────────────
3373
+ // Data Source / Embedding Management
3374
+ // TODO: Add support for 3rd party data sources (Google Drive, SharePoint, Confluence, etc.)
3375
+ // ─────────────────────────────────────────────────────────────────────────
3376
+ upload_data_source: async (args) => {
3377
+ const client = createClient(args.env);
3378
+ const personaId = String(args.persona_id);
3379
+ const filePath = String(args.file_path);
3380
+ const tags = args.tags ? String(args.tags) : undefined;
3381
+ // Read the file from filesystem
3382
+ const fs = await import("fs/promises");
3383
+ const path = await import("path");
3384
+ // Check if file exists
3385
+ try {
3386
+ await fs.access(filePath);
3387
+ }
3388
+ catch {
3389
+ throw new Error(`File not found: ${filePath}`);
3390
+ }
3391
+ const fileContent = await fs.readFile(filePath);
3392
+ const filename = path.basename(filePath);
3393
+ // Upload the file
3394
+ const result = await client.uploadDataSource(personaId, fileContent, filename, { tags });
3395
+ // Get the persona to update status_log
3396
+ const persona = await client.getPersonaById(personaId);
3397
+ if (persona) {
3398
+ const existingStatusLog = persona.status_log ?? {};
3399
+ const fileUploadLog = existingStatusLog.fileUpload ?? [];
3400
+ // Add new file to status log
3401
+ const updatedStatusLog = {
3402
+ ...existingStatusLog,
3403
+ fileUpload: [
3404
+ ...fileUploadLog,
3405
+ {
3406
+ id: result.fileId,
3407
+ status: "success",
3408
+ filename: result.filename,
3409
+ main_comment: "File ingested successfully.",
3410
+ sub_comments: [],
3411
+ },
3412
+ ],
3413
+ };
3414
+ // Update persona with new status_log
3415
+ await client.updateAiEmployee({
3416
+ persona_id: personaId,
3417
+ proto_config: persona.proto_config ?? {},
3418
+ status_log: updatedStatusLog,
3419
+ });
3420
+ }
3421
+ return {
3422
+ environment: client["env"].name,
3423
+ success: true,
3424
+ persona_id: personaId,
3425
+ file_id: result.fileId,
3426
+ filename: result.filename,
3427
+ status: result.status,
3428
+ message: `File "${filename}" uploaded successfully`,
3429
+ next_steps: [
3430
+ 'Enable RAG: knowledge(persona_id="<persona_id>", mode="toggle", enabled=true)',
3431
+ 'Verify upload: knowledge(persona_id="<persona_id>", mode="list")',
3432
+ ],
3433
+ };
3434
+ },
3435
+ delete_data_source: async (args) => {
3436
+ const client = createClient(args.env);
3437
+ const personaId = String(args.persona_id);
3438
+ const fileId = String(args.file_id);
3439
+ const result = await client.deleteDataSource(personaId, fileId);
3440
+ return {
3441
+ environment: client["env"].name,
3442
+ success: result.success,
3443
+ persona_id: personaId,
3444
+ file_id: fileId,
3445
+ message: `Data source file deleted`,
3446
+ };
3447
+ },
3448
+ list_data_sources: async (args) => {
3449
+ const client = createClient(args.env);
3450
+ const personaId = String(args.persona_id);
3451
+ // Get the persona to check its configuration
3452
+ const persona = await client.getPersonaById(personaId);
3453
+ if (!persona) {
3454
+ throw new Error(`AI Employee not found: ${personaId}`);
3455
+ }
3456
+ // Extract data source info from proto_config widgets
3457
+ const protoConfig = persona.proto_config;
3458
+ const widgets = (protoConfig?.widgets ?? []);
3459
+ // Find fileUpload widget configuration (use correct field names)
3460
+ const fileUploadWidget = widgets.find((w) => w.name === "fileUpload" || w.type === "FILE_UPLOAD" || w.type === 3);
3461
+ // Extract file count from widget config
3462
+ const fileUploadConfig = fileUploadWidget?.fileUpload;
3463
+ const localFiles = fileUploadConfig?.localFiles;
3464
+ const fileCount = localFiles?.[0]?.numFiles ? parseInt(localFiles[0].numFiles, 10) : 0;
3465
+ // Get status log for upload history
3466
+ const statusLog = persona.status_log;
3467
+ const fileUploadLog = statusLog?.fileUpload ?? [];
3468
+ // Try to get actual file list from API
3469
+ const files = await client.listDataSourceFiles(personaId);
3470
+ return {
3471
+ environment: client["env"].name,
3472
+ persona_id: personaId,
3473
+ persona_name: persona.name,
3474
+ embedding_enabled: persona.embedding_enabled ?? false,
3475
+ file_count: fileCount,
3476
+ files: files.length > 0 ? files : fileUploadLog,
3477
+ use_chunking: fileUploadConfig?.useChunking ?? false,
3478
+ workflow_id: persona.workflow_id,
3479
+ actions: [
3480
+ 'Upload: knowledge(persona_id="<persona_id>", mode="upload", file="/path/to/file.pdf")',
3481
+ 'Delete: knowledge(persona_id="<persona_id>", mode="delete", file_id="<file_id>")',
3482
+ 'Toggle embedding: knowledge(persona_id="<persona_id>", mode="toggle", enabled=true|false)',
3483
+ ],
3484
+ };
3485
+ },
3486
+ get_embedding_status: async (args) => {
3487
+ const client = createClient(args.env);
3488
+ const personaId = String(args.persona_id);
3489
+ const persona = await client.getPersonaById(personaId);
3490
+ if (!persona) {
3491
+ throw new Error(`AI Employee not found: ${personaId}`);
3492
+ }
3493
+ // Extract RAG-related configuration
3494
+ const protoConfig = persona.proto_config;
3495
+ const widgets = (protoConfig?.widgets ?? []);
3496
+ const fileUploadWidget = widgets.find((w) => w.name === "fileUpload" || w.type === "FILE_UPLOAD" || w.type === 3);
3497
+ const fileUploadConfig = fileUploadWidget?.fileUpload;
3498
+ return {
3499
+ environment: client["env"].name,
3500
+ persona_id: personaId,
3501
+ persona_name: persona.name,
3502
+ embedding_enabled: persona.embedding_enabled ?? false,
3503
+ file_upload_configured: !!fileUploadWidget,
3504
+ use_chunking: fileUploadConfig?.useChunking ?? false,
3505
+ note: persona.embedding_enabled
3506
+ ? "Embedding/RAG is enabled - uploaded documents will be indexed for search"
3507
+ : "Embedding/RAG is disabled - documents will not be indexed",
3508
+ };
3509
+ },
3510
+ toggle_embedding: async (args) => {
3511
+ const client = createClient(args.env);
3512
+ const personaId = String(args.persona_id);
3513
+ const enabled = Boolean(args.enabled);
3514
+ // Get existing persona to preserve other fields
3515
+ const persona = await client.getPersonaById(personaId);
3516
+ if (!persona) {
3517
+ throw new Error(`AI Employee not found: ${personaId}`);
3518
+ }
3519
+ // Update embedding status
3520
+ await client.updateAiEmployee({
3521
+ persona_id: personaId,
3522
+ proto_config: persona.proto_config ?? {},
3523
+ embedding_enabled: enabled,
3524
+ });
3525
+ return {
3526
+ environment: client["env"].name,
3527
+ success: true,
3528
+ persona_id: personaId,
3529
+ persona_name: persona.name,
3530
+ embedding_enabled: enabled,
3531
+ message: enabled
3532
+ ? "Embedding/RAG enabled - uploaded documents will be indexed for semantic search"
3533
+ : "Embedding/RAG disabled - documents will not be indexed",
3534
+ next_steps: enabled
3535
+ ? ['Upload documents: knowledge(persona_id="<persona_id>", mode="upload", file="/path/to/file.pdf")']
3536
+ : [],
3537
+ };
3538
+ },
3539
+ // ─────────────────────────────────────────────────────────────────────────
3540
+ // Unified Workflow Handler
3541
+ // ─────────────────────────────────────────────────────────────────────────
3542
+ workflow: async (args) => {
3543
+ const client = createClient(args.env);
3544
+ const personaId = args.persona_id ? String(args.persona_id) : undefined;
3545
+ const mode = args.mode ?? "generate";
3546
+ const useAutobuilder = args.use_autobuilder;
3547
+ const autoDeploy = Boolean(args.auto_deploy);
3548
+ const autoFix = args.auto_fix !== false; // Default true
3549
+ // ─────────────────────────────────────────────────────────────────────────
3550
+ // MODE: IMPROVE - Improve existing workflow
3551
+ // ─────────────────────────────────────────────────────────────────────────
3552
+ if (mode === "improve" && personaId) {
3553
+ const persona = await client.getPersonaById(personaId);
3554
+ if (!persona)
3555
+ throw new Error(`AI Employee not found: ${personaId}`);
3556
+ const currentWorkflow = persona.workflow_def;
3557
+ if (!currentWorkflow)
3558
+ throw new Error(`AI Employee "${persona.name}" has no workflow`);
3559
+ const issues = detectWorkflowIssues(currentWorkflow);
3560
+ if (issues.length === 0) {
3561
+ return {
3562
+ environment: client["env"].name,
3563
+ success: true,
3564
+ status: "no_issues",
3565
+ persona_id: personaId,
3566
+ persona_name: persona.name,
3567
+ message: "Workflow has no detected issues",
3568
+ };
3569
+ }
3570
+ // Try auto-fix first
3571
+ if (autoFix) {
3572
+ const fixResult = applyWorkflowFixes(currentWorkflow, issues, persona);
3573
+ if (fixResult.appliedFixes.some((f) => f.applied)) {
3574
+ const newIssues = detectWorkflowIssues(fixResult.fixedWorkflow);
3575
+ if (autoDeploy) {
3576
+ await client.updateAiEmployee({
3577
+ persona_id: personaId,
3578
+ proto_config: persona.proto_config ?? {},
3579
+ workflow: fixResult.fixedWorkflow,
3580
+ });
3581
+ }
3582
+ return {
3583
+ environment: client["env"].name,
3584
+ success: true,
3585
+ status: autoDeploy ? "deployed" : "improved",
3586
+ persona_id: personaId,
3587
+ persona_name: persona.name,
3588
+ fixes_applied: fixResult.appliedFixes.filter((f) => f.applied).map((f) => f.description),
3589
+ issues_before: issues.length,
3590
+ issues_after: newIssues.length,
3591
+ deployed: autoDeploy,
3592
+ workflow_def: autoDeploy ? undefined : fixResult.fixedWorkflow,
3593
+ };
3594
+ }
3595
+ }
3596
+ return {
3597
+ environment: client["env"].name,
3598
+ success: false,
3599
+ status: "needs_manual_fix",
3600
+ persona_id: personaId,
3601
+ issues,
3602
+ };
3603
+ }
3604
+ // ─────────────────────────────────────────────────────────────────────────
3605
+ // MODE: ANALYZE - Analyze only (no generation)
3606
+ // ─────────────────────────────────────────────────────────────────────────
3607
+ if (mode === "analyze" && personaId) {
3608
+ const persona = await client.getPersonaById(personaId);
3609
+ if (!persona)
3610
+ throw new Error(`AI Employee not found: ${personaId}`);
3611
+ const workflowDef = persona.workflow_def;
3612
+ if (!workflowDef) {
3613
+ return {
3614
+ environment: client["env"].name,
3615
+ success: true,
3616
+ status: "no_workflow",
3617
+ persona_id: personaId,
3618
+ persona_name: persona.name,
3619
+ };
3620
+ }
3621
+ const issues = detectWorkflowIssues(workflowDef);
3622
+ return {
3623
+ environment: client["env"].name,
3624
+ success: true,
3625
+ status: "analyzed",
3626
+ persona_id: personaId,
3627
+ persona_name: persona.name,
3628
+ issues,
3629
+ summary: {
3630
+ critical: issues.filter((i) => i.severity === "critical").length,
3631
+ warnings: issues.filter((i) => i.severity === "warning").length,
3632
+ },
3633
+ };
3634
+ }
3635
+ // ─────────────────────────────────────────────────────────────────────────
3636
+ // MODE: GENERATE - Generate new workflow from input
3637
+ // ─────────────────────────────────────────────────────────────────────────
3638
+ const input = args.input;
3639
+ if (!input && !personaId) {
3640
+ return {
3641
+ success: false,
3642
+ status: "missing_input",
3643
+ message: "Provide 'input' (description or spec) or 'persona_id' with mode='improve'",
3644
+ };
3645
+ }
3646
+ // Parse and normalize input
3647
+ const parseResult = parseInput(input);
3648
+ const { intent, input_type, validation } = parseResult;
3649
+ // Override persona_type if provided
3650
+ if (args.persona_type) {
3651
+ intent.persona_type = args.persona_type;
3652
+ }
3653
+ // If validation incomplete, return questions
3654
+ if (!validation.complete) {
3655
+ return {
3656
+ environment: client["env"].name,
3657
+ success: true,
3658
+ status: "needs_input",
3659
+ input_type,
3660
+ parsed_intent: intent,
3661
+ validation,
3662
+ message: "Need more information to generate workflow",
3663
+ questions: validation.questions,
3664
+ suggestions: validation.suggestions,
3665
+ };
3666
+ }
3667
+ // Decide: compile locally vs. use Auto Builder
3668
+ const isSimple = !intent.intents || intent.intents.length <= 2;
3669
+ const shouldUseAutobuilder = useAutobuilder ?? !isSimple;
3670
+ let workflowDef;
3671
+ let generationMethod;
3672
+ if (shouldUseAutobuilder) {
3673
+ // Use Auto Builder
3674
+ const prompt = generateAutobuilderPrompt(intent.description, intent.persona_type);
3675
+ const autobuilderId = await client.findAutobuilderPersona();
3676
+ const conv = await client.createAutobuilderConversation(autobuilderId);
3677
+ const result = await client.chatWithAutobuilder(conv.conversation_id, prompt);
3678
+ if (!result.workflow_def) {
3679
+ return {
3680
+ environment: client["env"].name,
3681
+ success: false,
3682
+ status: "generation_failed",
3683
+ message: "Auto Builder could not generate workflow. Try being more specific.",
3684
+ parsed_intent: intent,
3685
+ autobuilder_response: result.response,
3686
+ };
3687
+ }
3688
+ workflowDef = result.workflow_def;
3689
+ generationMethod = "autobuilder";
3690
+ }
3691
+ else {
3692
+ // Compile locally from intent
3693
+ const spec = intentToSpec(intent);
3694
+ const compiled = compileWorkflow(spec);
3695
+ workflowDef = compiled.workflow_def;
3696
+ generationMethod = "local_compile";
3697
+ }
3698
+ // Validate generated workflow
3699
+ const issues = detectWorkflowIssues(workflowDef);
3700
+ let fixesApplied = [];
3701
+ // Auto-fix if needed
3702
+ if (issues.length > 0 && autoFix) {
3703
+ const mockPersona = { id: personaId ?? "new", name: intent.name };
3704
+ const fixResult = applyWorkflowFixes(workflowDef, issues, mockPersona);
3705
+ if (fixResult.appliedFixes.some((f) => f.applied)) {
3706
+ workflowDef = fixResult.fixedWorkflow;
3707
+ fixesApplied = fixResult.appliedFixes.filter((f) => f.applied).map((f) => f.description);
3708
+ }
3709
+ }
3710
+ const finalIssues = detectWorkflowIssues(workflowDef);
3711
+ // Deploy if requested
3712
+ let deployed = false;
3713
+ if (personaId && autoDeploy) {
3714
+ const persona = await client.getPersonaById(personaId);
3715
+ if (persona) {
3716
+ await client.updateAiEmployee({
3717
+ persona_id: personaId,
3718
+ proto_config: persona.proto_config ?? {},
3719
+ workflow: workflowDef,
3720
+ });
3721
+ deployed = true;
3722
+ }
3723
+ }
3724
+ return {
3725
+ environment: client["env"].name,
3726
+ success: true,
3727
+ status: deployed ? "deployed" : "generated",
3728
+ input_type,
3729
+ generation_method: generationMethod,
3730
+ parsed_intent: intent,
3731
+ validation: {
3732
+ confidence: validation.confidence,
3733
+ suggestions: validation.suggestions,
3734
+ },
3735
+ issues_found: issues.length,
3736
+ auto_fixes: fixesApplied,
3737
+ remaining_issues: finalIssues.length,
3738
+ workflow_def: deployed ? undefined : workflowDef,
3739
+ deployed,
3740
+ persona_id: personaId,
3741
+ next_steps: deployed
3742
+ ? [`Verify: get_persona("${personaId}")`]
3743
+ : personaId
3744
+ ? [`Deploy: deploy_workflow("${personaId}", workflow_def)`]
3745
+ : ["Create AI Employee, then deploy workflow"],
3746
+ };
3747
+ },
3748
+ // ─────────────────────────────────────────────────────────────────────────
3749
+ // Demo Data Management Handlers
3750
+ // ─────────────────────────────────────────────────────────────────────────
3751
+ consolidate_demo_data: async (args) => {
3752
+ const fs = await import("fs");
3753
+ const path = await import("path");
3754
+ const sourceDir = String(args.source_dir);
3755
+ const outputDir = String(args.output_dir);
3756
+ const entityType = String(args.entity_type);
3757
+ const primaryFile = String(args.primary_file);
3758
+ const joins = (args.joins ?? []);
3759
+ const idField = String(args.id_field ?? "id");
3760
+ const nameField = String(args.name_field ?? "name");
3761
+ const tags = args.tags ? String(args.tags) : entityType;
3762
+ // Ensure directories exist
3763
+ if (!fs.existsSync(sourceDir)) {
3764
+ throw new Error(`Source directory not found: ${sourceDir}`);
3765
+ }
3766
+ fs.mkdirSync(outputDir, { recursive: true });
3767
+ // Load primary data
3768
+ const primaryPath = path.join(sourceDir, primaryFile);
3769
+ if (!fs.existsSync(primaryPath)) {
3770
+ throw new Error(`Primary file not found: ${primaryPath}`);
3771
+ }
3772
+ const primaryData = JSON.parse(fs.readFileSync(primaryPath, "utf-8"));
3773
+ // Load join data
3774
+ const joinData = {};
3775
+ for (const join of joins) {
3776
+ const joinPath = path.join(sourceDir, join.file);
3777
+ if (fs.existsSync(joinPath)) {
3778
+ joinData[join.as] = JSON.parse(fs.readFileSync(joinPath, "utf-8"));
3779
+ }
3780
+ }
3781
+ // Generate documents
3782
+ const generatedFiles = [];
3783
+ for (const entity of primaryData) {
3784
+ const entityId = String(entity[idField] ?? "unknown");
3785
+ const entityName = String(entity[nameField] ?? entityId);
3786
+ // Collect related data
3787
+ const related = {};
3788
+ for (const join of joins) {
3789
+ const data = joinData[join.as] ?? [];
3790
+ related[join.as] = data.filter((item) => item[join.on] === entityId);
3791
+ }
3792
+ // Generate document content
3793
+ const content = generateEntityDocument(entityType, entity, related, tags);
3794
+ // Write file
3795
+ const sanitizedName = entityName.toLowerCase().replace(/[^a-z0-9]+/g, "_");
3796
+ const sanitizedId = entityId.toLowerCase().replace(/[^a-z0-9]+/g, "_");
3797
+ const filename = `${entityType}_${sanitizedName}_${sanitizedId}.md`;
3798
+ const filePath = path.join(outputDir, filename);
3799
+ fs.writeFileSync(filePath, content);
3800
+ generatedFiles.push(filename);
3801
+ }
3802
+ return {
3803
+ success: true,
3804
+ source_dir: sourceDir,
3805
+ output_dir: outputDir,
3806
+ entity_type: entityType,
3807
+ entities_processed: primaryData.length,
3808
+ files_generated: generatedFiles,
3809
+ next_steps: [
3810
+ `Upload: for each file in ${outputDir}, call knowledge(persona_id="<persona_id>", mode="upload", file="/full/path/to/file.md")`,
3811
+ 'Enable RAG: knowledge(persona_id="<persona_id>", mode="toggle", enabled=true)',
3812
+ ],
3813
+ };
3814
+ },
3815
+ generate_demo_document: async (args) => {
3816
+ const fs = await import("fs");
3817
+ const entityType = String(args.entity_type);
3818
+ const data = args.data;
3819
+ const relatedData = (args.related_data ?? {});
3820
+ const outputPath = args.output_path ? String(args.output_path) : undefined;
3821
+ const tags = args.tags ? String(args.tags) : entityType;
3822
+ const content = generateEntityDocument(entityType, data, relatedData, tags);
3823
+ if (outputPath) {
3824
+ fs.writeFileSync(outputPath, content);
3825
+ return {
3826
+ success: true,
3827
+ output_path: outputPath,
3828
+ message: "Document generated and saved",
3829
+ };
3830
+ }
3831
+ return {
3832
+ success: true,
3833
+ content,
3834
+ message: "Document generated (not saved - no output_path provided)",
3835
+ };
3836
+ },
3837
+ validate_demo_document: async (args) => {
3838
+ const fs = await import("fs");
3839
+ let content;
3840
+ if (args.file_path) {
3841
+ if (!fs.existsSync(String(args.file_path))) {
3842
+ throw new Error(`File not found: ${args.file_path}`);
3843
+ }
3844
+ content = fs.readFileSync(String(args.file_path), "utf-8");
3845
+ }
3846
+ else if (args.content) {
3847
+ content = String(args.content);
3848
+ }
3849
+ else {
3850
+ throw new Error("Either file_path or content must be provided");
3851
+ }
3852
+ const issues = [];
3853
+ const suggestions = [];
3854
+ // Check for metadata comments
3855
+ if (!content.includes("<!-- ema_entity:")) {
3856
+ issues.push({ severity: "warning", message: "Missing ema_entity metadata comment" });
3857
+ suggestions.push("Add: <!-- ema_entity: customer | product | employee | scenario -->");
3858
+ }
3859
+ if (!content.includes("<!-- ema_id:")) {
3860
+ issues.push({ severity: "warning", message: "Missing ema_id metadata comment" });
3861
+ suggestions.push("Add: <!-- ema_id: unique-identifier -->");
3862
+ }
3863
+ if (!content.includes("<!-- ema_tags:")) {
3864
+ issues.push({ severity: "info", message: "Missing ema_tags metadata comment" });
3865
+ suggestions.push("Add: <!-- ema_tags: comma, separated, tags -->");
3866
+ }
3867
+ // Check for title
3868
+ if (!content.match(/^# /m)) {
3869
+ issues.push({ severity: "error", message: "Missing document title (# heading)" });
3870
+ }
3871
+ // Check for tables
3872
+ const hasTables = content.includes("|") && content.includes("---");
3873
+ if (!hasTables) {
3874
+ issues.push({ severity: "info", message: "No tables detected - consider adding structured data in table format" });
3875
+ }
3876
+ // Check for narrative content
3877
+ const paragraphCount = content.split(/\n\n/).filter((p) => p.trim().length > 100).length;
3878
+ if (paragraphCount < 1) {
3879
+ issues.push({ severity: "info", message: "Limited narrative content - consider adding relationship summaries for better semantic search" });
3880
+ }
3881
+ // Check filename convention (if file_path provided)
3882
+ if (args.file_path) {
3883
+ const filename = String(args.file_path).split("/").pop() ?? "";
3884
+ if (!filename.match(/^[a-z]+_[a-z0-9_]+\.md$/)) {
3885
+ issues.push({ severity: "info", message: `Filename '${filename}' doesn't follow convention: {entity}_{name}_{id}.md` });
3886
+ }
3887
+ }
3888
+ const criticalCount = issues.filter((i) => i.severity === "error").length;
3889
+ const warningCount = issues.filter((i) => i.severity === "warning").length;
3890
+ return {
3891
+ valid: criticalCount === 0,
3892
+ issues,
3893
+ suggestions,
3894
+ summary: {
3895
+ critical: criticalCount,
3896
+ warnings: warningCount,
3897
+ info: issues.filter((i) => i.severity === "info").length,
3898
+ },
3899
+ recommendation: criticalCount > 0
3900
+ ? "Fix critical issues before uploading"
3901
+ : warningCount > 0
3902
+ ? "Document is usable but consider addressing warnings"
3903
+ : "Document follows best practices",
3904
+ };
3905
+ },
3906
+ get_demo_data_template: async (args) => {
3907
+ const entityType = String(args.entity_type);
3908
+ const includeExample = args.include_example !== false;
3909
+ const templates = {
3910
+ customer: {
3911
+ sourceSchema: {
3912
+ id: "string (required) - Unique customer identifier",
3913
+ name: "string (required) - Company/customer name",
3914
+ industry: "string - Industry classification",
3915
+ tier: "string - Account tier (Enterprise, Mid-Market, SMB)",
3916
+ accountManager: "string - Assigned account manager",
3917
+ region: "string - Geographic region",
3918
+ annualRevenue: "number - Annual revenue",
3919
+ employeeCount: "string - Employee count range",
3920
+ contacts: "array - Key contacts [{name, role, email, phone, primary}]",
3921
+ },
3922
+ joinSuggestions: [
3923
+ { file: "orders.json", on: "customerId", as: "orders" },
3924
+ { file: "tickets.json", on: "customerId", as: "tickets" },
3925
+ { file: "notes.json", on: "customerId", as: "notes" },
3926
+ ],
3927
+ outputTemplate: `# Customer: {name}
3928
+
3929
+ ## Metadata
3930
+ <!-- ema_entity: customer -->
3931
+ <!-- ema_id: {id} -->
3932
+ <!-- ema_tags: {tier}, {industry} -->
3933
+
3934
+ ## Overview
3935
+ | Field | Value |
3936
+ |-------|-------|
3937
+ | Customer ID | {id} |
3938
+ | Company Name | {name} |
3939
+ | Industry | {industry} |
3940
+ | Account Tier | {tier} |
3941
+
3942
+ ## Relationship Summary
3943
+ [Narrative describing customer relationship, history, and status]
3944
+
3945
+ ## Orders
3946
+ | Order ID | Date | Amount | Status |
3947
+ |----------|------|--------|--------|
3948
+ [Order rows]
3949
+
3950
+ ## Support Tickets
3951
+ | Ticket ID | Subject | Priority | Status |
3952
+ |-----------|---------|----------|--------|
3953
+ [Ticket rows]
3954
+
3955
+ ## Key Contacts
3956
+ | Name | Role | Email | Primary |
3957
+ |------|------|-------|---------|
3958
+ [Contact rows]
3959
+ `,
3960
+ bestPractices: [
3961
+ "Include lifetime value calculation in Overview",
3962
+ "Add relationship summary with narrative context",
3963
+ "Include risk indicators for at-risk customers",
3964
+ "Cross-reference product SKUs in orders",
3965
+ ],
3966
+ example: includeExample ? {
3967
+ source: {
3968
+ id: "CUST-001",
3969
+ name: "Acme Corporation",
3970
+ industry: "Manufacturing",
3971
+ tier: "Enterprise",
3972
+ accountManager: "Sarah Johnson",
3973
+ contacts: [
3974
+ { name: "Lisa Park", role: "IT Director", email: "lisa@acme.com", primary: true },
3975
+ ],
3976
+ },
3977
+ related: {
3978
+ orders: [
3979
+ { id: "ORD-001", customerId: "CUST-001", date: "2024-01-15", amount: 45000, status: "Delivered" },
3980
+ ],
3981
+ tickets: [
3982
+ { id: "TKT-001", customerId: "CUST-001", subject: "API issue", priority: "High", status: "Open" },
3983
+ ],
3984
+ },
3985
+ } : undefined,
3986
+ },
3987
+ product: {
3988
+ sourceSchema: {
3989
+ sku: "string (required) - Product SKU",
3990
+ name: "string (required) - Product name",
3991
+ category: "string - Product category",
3992
+ price: "number - Unit price",
3993
+ description: "string - Product description",
3994
+ specifications: "object - Technical specifications",
3995
+ compatibility: "array - Compatible product SKUs",
3996
+ incompatibility: "array - Incompatible product SKUs with reasons",
3997
+ },
3998
+ joinSuggestions: [
3999
+ { file: "pricing_tiers.json", on: "sku", as: "pricing" },
4000
+ { file: "customer_purchases.json", on: "sku", as: "customers" },
4001
+ ],
4002
+ outputTemplate: `# Product: {name}
4003
+
4004
+ ## Metadata
4005
+ <!-- ema_entity: product -->
4006
+ <!-- ema_id: {sku} -->
4007
+ <!-- ema_tags: {category} -->
4008
+
4009
+ ## Basic Information
4010
+ | Field | Value |
4011
+ |-------|-------|
4012
+ | SKU | {sku} |
4013
+ | Name | {name} |
4014
+ | Category | {category} |
4015
+ | Price | {price} |
4016
+
4017
+ ## Description
4018
+ {description}
4019
+
4020
+ ## Specifications
4021
+ [Technical specs table]
4022
+
4023
+ ## Compatibility
4024
+ | Compatible With | SKU | Notes |
4025
+ |-----------------|-----|-------|
4026
+ [Compatibility rows]
4027
+
4028
+ ## Customer Examples
4029
+ | Customer | Quantity | Use Case |
4030
+ |----------|----------|----------|
4031
+ [Customer rows]
4032
+ `,
4033
+ bestPractices: [
4034
+ "Include compatibility matrix",
4035
+ "Add pricing tiers if applicable",
4036
+ "Cross-reference customer deployments",
4037
+ "Include common use cases",
4038
+ ],
4039
+ example: includeExample ? {
4040
+ source: {
4041
+ sku: "EWP-100",
4042
+ name: "Enterprise Widget Pro",
4043
+ category: "Widgets",
4044
+ price: 999,
4045
+ description: "Flagship enterprise widget for mission-critical applications",
4046
+ },
4047
+ } : undefined,
4048
+ },
4049
+ scenario: {
4050
+ sourceSchema: {
4051
+ id: "string (required) - Scenario identifier",
4052
+ name: "string (required) - Scenario name",
4053
+ description: "string - Scenario overview",
4054
+ difficulty: "string - Demo difficulty level",
4055
+ customerContext: "object - Customer data for the scenario",
4056
+ expectedQueries: "array - Queries the user might ask",
4057
+ idealResponse: "string - Example ideal AI response",
4058
+ },
4059
+ joinSuggestions: [],
4060
+ outputTemplate: `# Scenario: {name}
4061
+
4062
+ ## Metadata
4063
+ <!-- ema_scenario: {id} -->
4064
+ <!-- ema_tags: demo, {difficulty} -->
4065
+
4066
+ ## Overview
4067
+ {description}
4068
+
4069
+ ## Customer Context
4070
+ [Customer details for this scenario]
4071
+
4072
+ ## Expected Queries
4073
+ - "{query1}"
4074
+ - "{query2}"
4075
+
4076
+ ## Ideal Response Pattern
4077
+ [How the AI should respond]
4078
+ `,
4079
+ bestPractices: [
4080
+ "Include all context needed to answer expected queries",
4081
+ "Provide ideal response example",
4082
+ "List potential edge cases",
4083
+ "Include escalation triggers",
4084
+ ],
4085
+ example: includeExample ? {
4086
+ source: {
4087
+ id: "renewal_high_value",
4088
+ name: "High-Value Customer Renewal",
4089
+ description: "Customer up for renewal with competitor evaluation active",
4090
+ difficulty: "intermediate",
4091
+ },
4092
+ } : undefined,
4093
+ },
4094
+ employee: {
4095
+ sourceSchema: {
4096
+ id: "string (required) - Employee ID",
4097
+ name: "string (required) - Full name",
4098
+ department: "string - Department",
4099
+ role: "string - Job title",
4100
+ manager: "string - Manager name",
4101
+ email: "string - Email address",
4102
+ location: "string - Office location",
4103
+ startDate: "string - Start date",
4104
+ },
4105
+ joinSuggestions: [
4106
+ { file: "leave_balances.json", on: "employeeId", as: "leave" },
4107
+ { file: "benefits.json", on: "employeeId", as: "benefits" },
4108
+ ],
4109
+ outputTemplate: `# Employee: {name}
4110
+
4111
+ ## Metadata
4112
+ <!-- ema_entity: employee -->
4113
+ <!-- ema_id: {id} -->
4114
+ <!-- ema_tags: {department} -->
4115
+
4116
+ ## Profile
4117
+ | Field | Value |
4118
+ |-------|-------|
4119
+ | Employee ID | {id} |
4120
+ | Name | {name} |
4121
+ | Department | {department} |
4122
+ | Role | {role} |
4123
+ | Manager | {manager} |
4124
+
4125
+ ## Leave Balances
4126
+ | Type | Available | Used |
4127
+ |------|-----------|------|
4128
+ [Leave rows]
4129
+
4130
+ ## Benefits Enrollment
4131
+ | Benefit | Plan | Status |
4132
+ |---------|------|--------|
4133
+ [Benefits rows]
4134
+ `,
4135
+ bestPractices: [
4136
+ "Include manager hierarchy",
4137
+ "Add leave balances if HR use case",
4138
+ "Include benefits enrollment status",
4139
+ "Consider PII handling requirements",
4140
+ ],
4141
+ example: includeExample ? {
4142
+ source: {
4143
+ id: "EMP-001",
4144
+ name: "Jane Doe",
4145
+ department: "Engineering",
4146
+ role: "Senior Engineer",
4147
+ manager: "John Smith",
4148
+ },
4149
+ } : undefined,
4150
+ },
4151
+ reference: {
4152
+ sourceSchema: {
4153
+ category: "string (required) - Reference category",
4154
+ title: "string (required) - Document title",
4155
+ content: "string - Main content",
4156
+ lastUpdated: "string - Last update date",
4157
+ },
4158
+ joinSuggestions: [],
4159
+ outputTemplate: `# {title}
4160
+
4161
+ ## Metadata
4162
+ <!-- ema_doctype: reference -->
4163
+ <!-- ema_category: {category} -->
4164
+ <!-- ema_last_updated: {lastUpdated} -->
4165
+
4166
+ ## Content
4167
+ {content}
4168
+
4169
+ ## Related Topics
4170
+ [Cross-references to related documents]
4171
+ `,
4172
+ bestPractices: [
4173
+ "Include last updated date",
4174
+ "Cross-reference related documents",
4175
+ "Use consistent formatting",
4176
+ "Add search-friendly summaries",
4177
+ ],
4178
+ example: undefined,
4179
+ },
4180
+ };
4181
+ const template = templates[entityType];
4182
+ if (!template) {
4183
+ throw new Error(`Unknown entity type: ${entityType}. Available: ${Object.keys(templates).join(", ")}`);
4184
+ }
4185
+ return {
4186
+ entity_type: entityType,
4187
+ source_schema: template.sourceSchema,
4188
+ suggested_joins: template.joinSuggestions,
4189
+ output_template: template.outputTemplate,
4190
+ best_practices: template.bestPractices,
4191
+ example: template.example,
4192
+ usage: {
4193
+ consolidate: `demo(mode="consolidate", source="./data/source", output="./data/kb", entity="${entityType}", primary="${entityType}s.json", joins=${JSON.stringify(template.joinSuggestions)})`,
4194
+ generate_single: `demo(mode="generate", entity="${entityType}", data={...}, related={...})`,
4195
+ },
4196
+ };
4197
+ },
4198
+ // ═══════════════════════════════════════════════════════════════════════════
4199
+ // CONSOLIDATED TOOLS (Unix CLI pattern) - 9 tools replace 45
4200
+ // ═══════════════════════════════════════════════════════════════════════════
4201
+ env: async () => {
4202
+ return handleEnv({}, () => getAvailableEnvironments().map(e => ({
4203
+ name: e.name,
4204
+ isDefault: e.name === getDefaultEnvName(),
4205
+ })));
4206
+ },
4207
+ persona: async (args) => {
4208
+ const client = createClient(args.env);
4209
+ const DEFAULT_TEMPLATES = {
4210
+ voice: "00000000-0000-0000-0000-00000000001e",
4211
+ chat: "00000000-0000-0000-0000-000000000004",
4212
+ dashboard: "00000000-0000-0000-0000-000000000002",
4213
+ };
4214
+ return handlePersona(args, client, (type) => DEFAULT_TEMPLATES[type], (env) => createClient(env));
4215
+ },
4216
+ // Note: 'workflow' handler already exists above - consolidated version adds analyze modes
4217
+ // The existing 'workflow' handler is kept for backward compatibility
4218
+ action: async (args) => {
4219
+ const client = createClient(args.env);
4220
+ return handleAction(args, client);
4221
+ },
4222
+ template: async (args) => {
4223
+ return handleTemplate(args);
4224
+ },
4225
+ knowledge: async (args) => {
4226
+ const client = createClient(args.env);
4227
+ const fs = await import("fs/promises");
4228
+ return handleKnowledge(args, client, (path) => fs.readFile(path));
4229
+ },
4230
+ reference: async (args) => {
4231
+ return handleReference(args);
4232
+ },
4233
+ // Note: 'sync' handler already exists above - keeping it for full functionality
4234
+ // Note: demo is handled via a consolidated adapter below
4235
+ };
4236
+ // ─────────────────────────────────────────────────────────────────────────────
4237
+ // Consolidated Tool Adapters (contract ↔ implementation)
4238
+ // ─────────────────────────────────────────────────────────────────────────────
4239
+ //
4240
+ // The tool schemas in tools-consolidated.ts are the public MCP contract.
4241
+ // This file still contains a number of proven "legacy" handlers that we reuse
4242
+ // (e.g. deploy_workflow, optimize_workflow, compile_workflow, etc.).
4243
+ //
4244
+ // These adapters ensure the consolidated tool surface behaves as documented,
4245
+ // while preserving the existing internal implementations.
4246
+ const legacyWorkflowTool = toolHandlers.workflow;
4247
+ const legacyDeployWorkflow = toolHandlers.deploy_workflow;
4248
+ const legacyOptimizeWorkflow = toolHandlers.optimize_workflow;
4249
+ const legacyCompareWorkflowVersions = toolHandlers.compare_workflow_versions;
4250
+ const legacyCompileWorkflow = toolHandlers.compile_workflow;
4251
+ const legacyDetectWorkflowIssues = toolHandlers.detect_workflow_issues;
4252
+ const legacyValidateWorkflowConnections = toolHandlers.validate_workflow_connections;
4253
+ const legacySuggestWorkflowFixes = toolHandlers.suggest_workflow_fixes;
4254
+ const legacySyncRun = toolHandlers.sync;
4255
+ const legacySyncInfo = toolHandlers.sync_info;
4256
+ const legacyConsolidateDemoData = toolHandlers.consolidate_demo_data;
4257
+ const legacyGenerateDemoDocument = toolHandlers.generate_demo_document;
4258
+ const legacyValidateDemoDocument = toolHandlers.validate_demo_document;
4259
+ const legacyGetDemoDataTemplate = toolHandlers.get_demo_data_template;
4260
+ // Unify workflow modes: generate | analyze | deploy | optimize | compare | compile
4261
+ toolHandlers.workflow = async (args) => {
4262
+ // Normalize persona type alias: tool schema uses "type", internal uses "persona_type"
4263
+ const normalizedArgs = { ...(args ?? {}) };
4264
+ if (normalizedArgs.persona_type === undefined && normalizedArgs.type !== undefined) {
4265
+ normalizedArgs.persona_type = normalizedArgs.type;
4266
+ }
4267
+ const personaId = normalizedArgs.persona_id ? String(normalizedArgs.persona_id) : undefined;
4268
+ const workflowDef = normalizedArgs.workflow_def;
4269
+ const inputProvided = normalizedArgs.input !== undefined;
4270
+ // Normalize mode aliases
4271
+ const rawMode = normalizedArgs.mode ? String(normalizedArgs.mode) : undefined;
4272
+ const mode = rawMode === "improve" ? "optimize" : rawMode;
4273
+ // Default mode selection:
4274
+ // - persona_id + input → optimize (BROWNFIELD: enhance existing workflow)
4275
+ // - persona_id only or workflow_def → analyze (inspect)
4276
+ // - input only → generate (GREENFIELD: create from scratch)
4277
+ // - Otherwise → generate (and prompt for missing info)
4278
+ const effectiveMode = mode ??
4279
+ (personaId && inputProvided ? "optimize" : // BROWNFIELD: existing persona + new requirements
4280
+ workflowDef || personaId ? "analyze" : // Inspect existing
4281
+ "generate"); // GREENFIELD: new workflow
4282
+ switch (effectiveMode) {
4283
+ case "generate": {
4284
+ const result = await legacyWorkflowTool({ ...normalizedArgs, mode: "generate" });
4285
+ // Ensure next_steps point at consolidated tools by default
4286
+ if (result && typeof result === "object") {
4287
+ const obj = result;
4288
+ const persona_id = obj.persona_id ? String(obj.persona_id) : personaId;
4289
+ const deployed = obj.deployed === true;
4290
+ obj.next_steps = deployed
4291
+ ? persona_id
4292
+ ? [`Verify: persona(id="${persona_id}", include_workflow=true)`]
4293
+ : ["Verify the deployed AI Employee in the Ema UI"]
4294
+ : persona_id
4295
+ ? [
4296
+ `Deploy: workflow(mode="deploy", persona_id="${persona_id}", workflow_def=<workflow_def>, proto_config=<proto_config>)`,
4297
+ `Verify: persona(id="${persona_id}", include_workflow=true)`,
4298
+ ]
4299
+ : [
4300
+ "Create an AI Employee in Ema, then deploy:",
4301
+ `workflow(mode="deploy", persona_id="<persona_id>", workflow_def=<workflow_def>, proto_config=<proto_config>)`,
4302
+ ];
4303
+ }
4304
+ return result;
4305
+ }
4306
+ case "analyze": {
4307
+ const include = Array.isArray(normalizedArgs.include)
4308
+ ? normalizedArgs.include.map(String)
4309
+ : ["issues", "connections", "fixes", "metrics"];
4310
+ let wf = workflowDef;
4311
+ let meta;
4312
+ if (!wf && personaId) {
4313
+ const client = createClient(normalizedArgs.env);
4314
+ const persona = await client.getPersonaById(personaId);
4315
+ if (!persona)
4316
+ throw new Error(`AI Employee not found: ${personaId}`);
4317
+ wf = persona.workflow_def;
4318
+ meta = {
4319
+ persona_id: personaId,
4320
+ persona_name: persona.name,
4321
+ environment: client["env"].name,
4322
+ };
4323
+ }
4324
+ if (!wf) {
4325
+ return {
4326
+ error: "Missing workflow to analyze. Provide persona_id or workflow_def.",
4327
+ hint: 'Examples: workflow(mode="analyze", persona_id="...") or workflow(mode="analyze", workflow_def={...})',
4328
+ };
4329
+ }
4330
+ const out = { mode: "analyze", ...(meta ?? {}) };
4331
+ // Issues + summary (source of truth)
4332
+ const issuesResult = await legacyDetectWorkflowIssues({ workflow_def: wf });
4333
+ const issues = issuesResult.issues;
4334
+ if (include.includes("issues")) {
4335
+ out.issues = issuesResult.issues;
4336
+ out.issue_summary = issuesResult.summary;
4337
+ out.validation_passed = issuesResult.validation_passed;
4338
+ }
4339
+ if (include.includes("connections")) {
4340
+ out.connections = await legacyValidateWorkflowConnections({ workflow_def: wf });
4341
+ }
4342
+ if (include.includes("fixes")) {
4343
+ out.fixes = Array.isArray(issues) ? await legacySuggestWorkflowFixes({ issues }) : { error: "No issues array available to generate fixes" };
4344
+ }
4345
+ if (include.includes("metrics")) {
4346
+ const actions = wf.actions ?? [];
4347
+ const edges = wf.edges ?? [];
4348
+ out.metrics = {
4349
+ node_count: Array.isArray(actions) ? actions.length : 0,
4350
+ edge_count: Array.isArray(edges) ? edges.length : 0,
4351
+ };
4352
+ }
4353
+ return out;
4354
+ }
4355
+ case "deploy": {
4356
+ if (!personaId) {
4357
+ throw new Error('persona_id is required for workflow(mode="deploy")');
4358
+ }
4359
+ const validateFirst = normalizedArgs.validate !== false; // default true
4360
+ const autoFix = normalizedArgs.auto_fix === true; // default false
4361
+ return legacyDeployWorkflow({
4362
+ persona_id: personaId,
4363
+ workflow_def: workflowDef,
4364
+ proto_config: normalizedArgs.proto_config,
4365
+ validate_first: validateFirst,
4366
+ auto_fix: autoFix,
4367
+ env: normalizedArgs.env,
4368
+ });
4369
+ }
4370
+ case "optimize": {
4371
+ // optimize_workflow supports both:
4372
+ // - fix mode: id (name/id) OR persona_id
4373
+ // - generate mode: persona_id + prompt
4374
+ const preview = normalizedArgs.preview === true;
4375
+ const id = normalizedArgs.id ? String(normalizedArgs.id) : undefined;
4376
+ const identifier = normalizedArgs.identifier ? String(normalizedArgs.identifier) : undefined; // deprecated alias
4377
+ const idOrIdentifier = id ?? identifier;
4378
+ // If a natural-language workflow "input" is provided, treat it as a prompt to optimize_workflow.
4379
+ const input = normalizedArgs.input;
4380
+ const prompt = typeof input === "string" ? input : undefined;
4381
+ return legacyOptimizeWorkflow({
4382
+ identifier: idOrIdentifier ?? personaId,
4383
+ persona_id: personaId,
4384
+ prompt,
4385
+ type: normalizedArgs.persona_type,
4386
+ preview,
4387
+ env: normalizedArgs.env,
4388
+ });
4389
+ }
4390
+ case "compare": {
4391
+ if (!personaId) {
4392
+ throw new Error('persona_id is required for workflow(mode="compare")');
4393
+ }
4394
+ const compareTo = normalizedArgs.compare_to ? String(normalizedArgs.compare_to) : undefined;
4395
+ if (!compareTo) {
4396
+ throw new Error('compare_to is required for workflow(mode="compare")');
4397
+ }
4398
+ return legacyCompareWorkflowVersions({
4399
+ persona_id_before: personaId,
4400
+ persona_id_after: compareTo,
4401
+ env: normalizedArgs.env,
4402
+ });
4403
+ }
4404
+ case "compile": {
4405
+ // Compile requires node specs + result mappings.
4406
+ if (!normalizedArgs.name || !normalizedArgs.description) {
4407
+ throw new Error('workflow(mode="compile") requires name and description');
4408
+ }
4409
+ if (!normalizedArgs.persona_type) {
4410
+ throw new Error('workflow(mode="compile") requires persona type via type="voice|chat|dashboard"');
4411
+ }
4412
+ return legacyCompileWorkflow({
4413
+ name: normalizedArgs.name,
4414
+ description: normalizedArgs.description,
4415
+ persona_type: normalizedArgs.persona_type,
4416
+ nodes: normalizedArgs.nodes,
4417
+ result_mappings: normalizedArgs.result_mappings,
4418
+ });
4419
+ }
4420
+ default: {
4421
+ return legacyWorkflowTool({ ...normalizedArgs, mode: effectiveMode });
4422
+ }
4423
+ }
4424
+ };
4425
+ // Unify sync modes: run | status | config
4426
+ toolHandlers.sync = async (args) => {
4427
+ const normalizedArgs = { ...(args ?? {}) };
4428
+ const rawMode = normalizedArgs.mode ? String(normalizedArgs.mode) : "run";
4429
+ const mode = rawMode;
4430
+ // Support both old and new arg names
4431
+ const target = (normalizedArgs.target ?? normalizedArgs.target_env);
4432
+ const source = (normalizedArgs.source ?? normalizedArgs.source_env);
4433
+ const id = normalizedArgs.id;
4434
+ const identifier = normalizedArgs.identifier; // deprecated alias
4435
+ const idOrIdentifier = id ?? identifier;
4436
+ if (mode === "config") {
4437
+ return legacySyncInfo({ include_options: true });
4438
+ }
4439
+ if (mode === "status") {
4440
+ const env = normalizedArgs.env;
4441
+ if (normalizedArgs.list_synced === true) {
4442
+ if (!env)
4443
+ throw new Error('env is required for sync(mode="status", list_synced=true)');
4444
+ return legacySyncInfo({ list_synced: true, master_env: normalizedArgs.master_env, env });
4445
+ }
4446
+ if (idOrIdentifier) {
4447
+ if (!env)
4448
+ throw new Error('env is required for sync(mode="status", id="...")');
4449
+ const identifierToResolve = String(idOrIdentifier);
4450
+ const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(identifierToResolve);
4451
+ if (isUUID) {
4452
+ return legacySyncInfo({ persona_id: identifierToResolve, env });
4453
+ }
4454
+ // Name lookup: resolve to ID in env, then reuse persona_id path
4455
+ const client = createClient(env);
4456
+ const personas = await client.getPersonasForTenant();
4457
+ const match = personas.find((p) => p.name === identifierToResolve);
4458
+ if (!match)
4459
+ throw new Error(`AI Employee not found by name in ${env}: ${identifierToResolve}`);
4460
+ return legacySyncInfo({ persona_id: match.id, env });
4461
+ }
4462
+ // Default: overall sync status/config summary
4463
+ return legacySyncInfo({ include_options: normalizedArgs.include_options === true });
4464
+ }
4465
+ // mode === "run" (default)
4466
+ if (!target) {
4467
+ throw new Error('target (or target_env) is required for sync(mode="run")');
4468
+ }
4469
+ return legacySyncRun({
4470
+ identifier: idOrIdentifier,
4471
+ target_env: target,
4472
+ source_env: source,
4473
+ scope: normalizedArgs.scope,
4474
+ dry_run: normalizedArgs.dry_run,
4475
+ include_status: normalizedArgs.include_status,
4476
+ });
4477
+ };
4478
+ // Consolidated demo tool: consolidate | generate | validate | template
4479
+ toolHandlers.demo = async (args) => {
4480
+ const normalizedArgs = { ...(args ?? {}) };
4481
+ const mode = normalizedArgs.mode ? String(normalizedArgs.mode) : "template";
4482
+ switch (mode) {
4483
+ case "consolidate": {
4484
+ const source = String(normalizedArgs.source ?? "");
4485
+ const output = String(normalizedArgs.output ?? "");
4486
+ const entity = String(normalizedArgs.entity ?? "");
4487
+ if (!source || !output || !entity) {
4488
+ throw new Error('demo(mode="consolidate") requires: source, output, entity');
4489
+ }
4490
+ return legacyConsolidateDemoData({
4491
+ source_dir: source,
4492
+ output_dir: output,
4493
+ entity_type: entity,
4494
+ primary_file: normalizedArgs.primary ?? `${entity}s.json`,
4495
+ joins: normalizedArgs.joins ?? [],
4496
+ tags: normalizedArgs.tags,
4497
+ });
4498
+ }
4499
+ case "generate": {
4500
+ const entity = String(normalizedArgs.entity ?? "");
4501
+ if (!entity)
4502
+ throw new Error('demo(mode="generate") requires: entity');
4503
+ return legacyGenerateDemoDocument({
4504
+ entity_type: entity,
4505
+ data: normalizedArgs.data ?? {},
4506
+ related_data: normalizedArgs.related ?? {},
4507
+ output_path: normalizedArgs.output,
4508
+ tags: normalizedArgs.tags,
4509
+ });
4510
+ }
4511
+ case "validate": {
4512
+ return legacyValidateDemoDocument({
4513
+ file_path: normalizedArgs.file,
4514
+ content: normalizedArgs.content,
4515
+ });
4516
+ }
4517
+ case "template": {
4518
+ const entity = String(normalizedArgs.entity ?? "");
4519
+ if (!entity)
4520
+ throw new Error('demo(mode="template") requires: entity');
4521
+ return legacyGetDemoDataTemplate({
4522
+ entity_type: entity,
4523
+ include_example: normalizedArgs.include_example,
4524
+ });
4525
+ }
4526
+ default:
4527
+ throw new Error(`Unknown demo mode: ${mode}`);
4528
+ }
4529
+ };
4530
+ // Helper function for document generation
4531
+ function generateEntityDocument(entityType, entity, related, tags) {
4532
+ const id = String(entity.id ?? entity.sku ?? entity.employeeId ?? "unknown");
4533
+ const name = String(entity.name ?? entity.title ?? id);
4534
+ let content = `# ${entityType.charAt(0).toUpperCase() + entityType.slice(1)}: ${name}
4535
+
4536
+ ## Metadata
4537
+ <!-- ema_entity: ${entityType} -->
4538
+ <!-- ema_id: ${id} -->
4539
+ <!-- ema_tags: ${tags} -->
4540
+ <!-- ema_generated: ${new Date().toISOString().split("T")[0]} -->
4541
+
4542
+ ## Overview
4543
+ | Field | Value |
4544
+ |-------|-------|
4545
+ `;
4546
+ // Add entity fields to overview table
4547
+ const skipFields = ["contacts", "items", "notes"];
4548
+ for (const [key, value] of Object.entries(entity)) {
4549
+ if (!skipFields.includes(key) && value !== null && value !== undefined && typeof value !== "object") {
4550
+ const displayKey = key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase());
4551
+ const displayValue = typeof value === "number" && key.toLowerCase().includes("revenue")
4552
+ ? `$${value.toLocaleString()}`
4553
+ : String(value);
4554
+ content += `| ${displayKey} | ${displayValue} |\n`;
4555
+ }
4556
+ }
4557
+ // Add relationship summary
4558
+ const relationshipParts = [];
4559
+ for (const [key, items] of Object.entries(related)) {
4560
+ if (items.length > 0) {
4561
+ relationshipParts.push(`${items.length} ${key}`);
4562
+ }
4563
+ }
4564
+ if (relationshipParts.length > 0) {
4565
+ content += `\n## Relationship Summary\nThis ${entityType} has ${relationshipParts.join(", ")}.\n`;
4566
+ }
4567
+ // Add contacts if present
4568
+ const contacts = entity.contacts;
4569
+ if (contacts && contacts.length > 0) {
4570
+ content += `\n## Key Contacts
4571
+ | Name | Role | Email | Primary |
4572
+ |------|------|-------|---------|
4573
+ `;
4574
+ for (const contact of contacts) {
4575
+ content += `| ${contact.name ?? ""} | ${contact.role ?? ""} | ${contact.email ?? ""} | ${contact.primary ? "Yes" : "No"} |\n`;
4576
+ }
4577
+ }
4578
+ // Add related data sections
4579
+ for (const [key, items] of Object.entries(related)) {
4580
+ if (items.length > 0) {
4581
+ const sectionTitle = key.charAt(0).toUpperCase() + key.slice(1);
4582
+ content += `\n## ${sectionTitle} (${items.length} total)\n`;
4583
+ // Get all unique keys from items
4584
+ const allKeys = new Set();
4585
+ for (const item of items) {
4586
+ const itemObj = item;
4587
+ for (const k of Object.keys(itemObj)) {
4588
+ if (typeof itemObj[k] !== "object")
4589
+ allKeys.add(k);
4590
+ }
4591
+ }
4592
+ const keys = Array.from(allKeys).slice(0, 6); // Limit columns
4593
+ // Create table header
4594
+ content += `| ${keys.map((k) => k.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())).join(" | ")} |\n`;
4595
+ content += `|${keys.map(() => "---").join("|")}|\n`;
4596
+ // Add rows
4597
+ for (const item of items.slice(0, 10)) { // Limit rows
4598
+ const itemObj = item;
4599
+ const values = keys.map((k) => {
4600
+ const v = itemObj[k];
4601
+ if (typeof v === "number" && k.toLowerCase().includes("amount")) {
4602
+ return `$${v.toLocaleString()}`;
4603
+ }
4604
+ return String(v ?? "");
4605
+ });
4606
+ content += `| ${values.join(" | ")} |\n`;
4607
+ }
4608
+ if (items.length > 10) {
4609
+ content += `\n*...and ${items.length - 10} more*\n`;
4610
+ }
4611
+ }
4612
+ }
4613
+ return content;
4614
+ }
4615
+ // ─────────────────────────────────────────────────────────────────────────────
4616
+ // MCP Server Setup
4617
+ // ─────────────────────────────────────────────────────────────────────────────
4618
+ // Initialize registries
4619
+ const promptRegistry = new PromptRegistry();
4620
+ const resourceRegistry = new ResourceRegistry();
4621
+ export async function startMcpServer() {
4622
+ const server = new Server({ name: "ema", version: "1.0.0" }, {
4623
+ capabilities: {
4624
+ tools: {},
4625
+ prompts: {},
4626
+ resources: {},
4627
+ }
4628
+ });
4629
+ // ─────────────────────────────────────────────────────────────────────────
4630
+ // Tool Handlers
4631
+ // ─────────────────────────────────────────────────────────────────────────
4632
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
4633
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4634
+ const { name, arguments: args } = request.params;
4635
+ const handler = toolHandlers[name];
4636
+ if (!handler) {
4637
+ return {
4638
+ content: [{ type: "text", text: JSON.stringify({ error: `Unknown tool: ${name}` }) }],
4639
+ isError: true,
4640
+ };
4641
+ }
4642
+ try {
4643
+ const result = await handler((args ?? {}));
4644
+ return {
4645
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
4646
+ };
4647
+ }
4648
+ catch (error) {
4649
+ return {
4650
+ content: [{ type: "text", text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }) }],
4651
+ isError: true,
4652
+ };
4653
+ }
4654
+ });
4655
+ // ─────────────────────────────────────────────────────────────────────────
4656
+ // Prompt Handlers
4657
+ // ─────────────────────────────────────────────────────────────────────────
4658
+ server.setRequestHandler(ListPromptsRequestSchema, async () => {
4659
+ const prompts = promptRegistry.list();
4660
+ return {
4661
+ prompts: prompts.map((p) => ({
4662
+ name: p.name,
4663
+ description: p.description,
4664
+ arguments: p.arguments.map((a) => ({
4665
+ name: a.name,
4666
+ description: a.description,
4667
+ required: a.required,
4668
+ })),
4669
+ })),
4670
+ };
4671
+ });
4672
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
4673
+ const { name, arguments: args } = request.params;
4674
+ const result = promptRegistry.get(name, args);
4675
+ if (isPromptError(result)) {
4676
+ throw new Error(`${result.code}: ${result.message}`);
4677
+ }
4678
+ return {
4679
+ description: result.description,
4680
+ messages: result.messages.map((m) => ({
4681
+ role: m.role,
4682
+ content: m.content,
4683
+ })),
4684
+ };
4685
+ });
4686
+ // ─────────────────────────────────────────────────────────────────────────
4687
+ // Resource Handlers
4688
+ // ─────────────────────────────────────────────────────────────────────────
4689
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
4690
+ const resources = resourceRegistry.list();
4691
+ return {
4692
+ resources: resources.map((r) => ({
4693
+ uri: r.uri,
4694
+ name: r.name,
4695
+ description: r.description,
4696
+ mimeType: r.mimeType,
4697
+ })),
4698
+ };
4699
+ });
4700
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
4701
+ const { uri } = request.params;
4702
+ const result = await resourceRegistry.read(uri);
4703
+ if (isResourceError(result)) {
4704
+ throw new Error(`${result.code}: ${result.message}`);
4705
+ }
4706
+ return {
4707
+ contents: [
4708
+ {
4709
+ uri: result.uri,
4710
+ mimeType: result.mimeType,
4711
+ text: result.text,
4712
+ },
4713
+ ],
4714
+ };
4715
+ });
4716
+ const transport = new StdioServerTransport();
4717
+ await server.connect(transport);
4718
+ console.error("Ema MCP Server started (multi-env) with prompts and resources");
4719
+ }
4720
+ startMcpServer().catch((error) => {
4721
+ console.error("Failed to start MCP server:", error);
4722
+ process.exit(1);
4723
+ });